@@ -658,7 +1091,7 @@
- WaveNet & Neural2 Voices
+ WaveNet & Neural2
$16.00
per million characters
@@ -666,7 +1099,7 @@
- Studio Voices
+ Studio Quality
$160.00
per million characters
@@ -688,12 +1121,12 @@
-
* Prices are based on Google Cloud Text-to-Speech API pricing as of 2024.
-
* Actual costs may vary. Check Google Cloud Console for current pricing.
+
* Prices are based on Google Cloud Text-to-Speech API pricing.
+
* Standard voices are recommended for most use cases.
-
@@ -706,124 +1139,61 @@
modal.remove();
}
});
- }
-
- function initAudioContext() {
- try {
- audioContext = new (window.AudioContext || window.webkitAudioContext)();
- } catch (e) {
- console.error('Web Audio API not supported', e);
- showToast('Your browser does not support the Web Audio API. Please use Chrome or Edge.', 'error');
- }
- }
-
- // 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);
- } else {
- document.documentElement.setAttribute('data-theme', 'light');
- updateDarkModeIcon(false);
- }
+ // 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];
- darkModeToggle.addEventListener('click', toggleDarkMode);
- }
-
- function toggleDarkMode() {
- const currentTheme = document.documentElement.getAttribute('data-theme');
- const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
+ firstElement.focus();
- document.documentElement.setAttribute('data-theme', newTheme);
- localStorage.setItem('theme', newTheme);
- updateDarkModeIcon(newTheme === 'dark');
- }
-
- function updateDarkModeIcon(isDark) {
- const icon = darkModeToggle.querySelector('i');
- icon.className = isDark ? 'fas fa-sun text-xl' : 'fas fa-moon text-xl';
- }
-
- // 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();
+ 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();
+ }
+ }
}
- };
- }
-
- // Download feature initialization
- function initDownloadFeature() {
- downloadBtn.addEventListener('click', downloadAudio);
+ });
}
- async function downloadAudio() {
- if (!currentAudioBlob) {
- showToast('No audio available to download', 'error');
- return;
- }
-
+ function initAudioContext() {
try {
- const url = URL.createObjectURL(currentAudioBlob);
- const a = document.createElement('a');
- a.href = url;
-
- // Create a more descriptive filename
- const timestamp = new Date().toISOString().slice(0, -5).replace(/[T:]/g, '-');
- const voiceName = selectedVoice ? selectedVoice.name.replace(/[^a-zA-Z0-9]/g, '-') : 'unknown';
- a.download = `tts-${voiceName}-${timestamp}.mp3`;
-
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-
- showToast('Audio downloaded successfully', 'success');
- } catch (error) {
- console.error('Download error:', error);
- showToast('Failed to download audio', 'error');
+ 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');
}
}
- // Toast notifications
+ // Enhanced toast notifications
function showToast(message, type = 'success', duration = 3000) {
- // Remove existing toasts
- document.querySelectorAll('.toast').forEach(toast => toast.remove());
-
const toast = document.createElement('div');
toast.className = `toast ${type}`;
- toast.textContent = message;
+ 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 = `
+
+
+ ${message}
+
+ `;
+
document.body.appendChild(toast);
setTimeout(() => {
@@ -832,15 +1202,6 @@
}, duration);
}
- // 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];
- }
-
// API Key Management
saveKeyBtn.addEventListener('click', async () => {
const key = apiKeyInput.value ? apiKeyInput.value.trim() : '';
@@ -859,6 +1220,8 @@
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',
@@ -876,14 +1239,17 @@
showToast('API key validated and saved successfully', 'success');
loadVoices();
} else {
- throw new Error('API key validation failed. Please check the key and your Google Cloud settings.');
+ 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 = '
Save Key';
saveKeyBtn.disabled = false;
+ hideLoadingOverlay();
}
} else {
showToast('Please enter a valid API key', 'error');
@@ -893,14 +1259,18 @@
// Load available voices from Google TTS
async function loadVoices() {
if (!apiKey) {
- voiceSelect.innerHTML = '
';
+ updateVoiceStatus('error', 'API key required');
+ voiceSelect.innerHTML = '
';
return;
}
- voiceSelect.innerHTML = '
';
+ updateVoiceStatus('loading', 'Loading voices...');
+ voiceSelect.innerHTML = '
';
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: {
@@ -912,7 +1282,8 @@
});
if (!response.ok) {
- throw new Error(`API error (${response.status}): Failed to load voices`);
+ 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();
@@ -921,16 +1292,24 @@
throw new Error('Invalid response from Google TTS API - no voices returned');
}
- // Store all voices and filter initially by standard model
- availableVoices = data.voices;
- filterVoicesByModel('standard'); // Always start with standard model
+ // Store all voices and populate dropdown
+ availableVoices = data.voices.sort((a, b) => a.name.localeCompare(b.name));
+ populateVoiceDropdown(availableVoices);
+
+ // Auto-select standard voice by default
+ autoSelectVoiceForModel('standard');
- showToast(`Loaded ${availableVoices.length} voices`, 'success');
+ 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 = '
';
- showToast('Failed to load voices: ' + error.message, 'error');
+ showToast(`Failed to load voices: ${error.message}`, 'error', 5000);
+ } finally {
+ hideLoadingOverlay();
}
}
@@ -938,25 +1317,55 @@
function populateVoiceDropdown(voices) {
voiceSelect.innerHTML = '
';
+ // Group voices by type for better organization
+ const groupedVoices = {
+ standard: [],
+ wavenet: [],
+ neural2: [],
+ studio: []
+ };
+
voices.forEach(voice => {
- const option = document.createElement('option');
- option.value = voice.name;
- option.textContent = `${voice.name} (${voice.ssmlGender})`;
- voiceSelect.appendChild(option);
+ 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);
+ }
});
}
- // Show/hide synthesis progress
- function showSynthesisProgress(progress) {
- synthesisProgress.classList.remove('hidden');
- progressPercentage.textContent = `${Math.round(progress)}%`;
- }
-
- function hideSynthesisProgress() {
- synthesisProgress.classList.add('hidden');
- }
-
- // Synthesize speech with progress tracking
+ // Enhanced synthesize speech with progress tracking
async function synthesizeSpeech(text, voice, rate = 1, pitch = 0, model = 'standard') {
if (!apiKey) {
throw new Error('API key not provided');
@@ -990,6 +1399,9 @@
audioConfig: audioConfig
};
+ const voiceType = getVoiceTypeAndPricing(voice.name);
+ const cost = (text.length / 1000000) * voiceType.rate;
+
try {
let response;
try {
@@ -1049,6 +1461,7 @@
throw new Error('Invalid response from Google TTS API - no audio content received');
}
+ showToast(`Generated audio chunk (~$${cost.toFixed(4)})`, 'success', 1500);
return data.audioContent;
} catch (error) {
console.error('Synthesis error:', error);
@@ -1078,6 +1491,7 @@
startTime = audioContext.currentTime;
startProgressTracking();
+ showStatus('Playing audio', 'fa-play', 'persistent');
audioSource.onended = () => {
if (isPlaying && !isPaused) {
@@ -1122,7 +1536,7 @@
el.classList.remove('reading-highlight');
});
- const paragraphs = documentContent.children;
+ const paragraphs = Array.from(documentContent.children);
let pos = 0;
for (let i = 0; i < paragraphs.length; i++) {
@@ -1131,6 +1545,8 @@
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;
@@ -1152,20 +1568,45 @@
}
});
- pasteBtn.addEventListener('click', () => {
- documentContent.innerHTML = '
Press Ctrl+V (Cmd+V on Mac) to paste your text...
';
- documentContent.focus();
-
- const pasteHandler = (e) => {
- e.preventDefault();
- const text = (e.clipboardData || window.clipboardData).getData('text');
+ pasteBtn.addEventListener('click', async () => {
+ try {
+ const text = await navigator.clipboard.readText();
if (text) {
setDocumentContent(text);
- document.removeEventListener('paste', pasteHandler);
+ showToast('Text pasted successfully', 'success');
+ } else {
+ throw new Error('Clipboard is empty');
}
- };
-
- document.addEventListener('paste', pasteHandler);
+ } catch (error) {
+ // Fallback for older browsers
+ documentContent.innerHTML = '
Press Ctrl+V (Cmd+V on Mac) to paste your text...
';
+ 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
@@ -1212,13 +1653,8 @@
const fileExtension = file.name.toLowerCase().split('.').pop();
- documentContent.innerHTML = `
-
-
-
Processing ${file.name}...
-
File size: ${formatFileSize(file.size)}
-
- `;
+ showLoadingOverlay('Processing File', `Loading ${file.name}...`);
+ showStatus(`Processing file: ${file.name}`, 'fa-file-import', 'persistent');
try {
switch (fileExtension) {
@@ -1262,7 +1698,8 @@
} catch (error) {
console.error('Error processing file:', error);
showToast('Error processing file: ' + error.message, 'error');
- documentContent.innerHTML = '
Error loading document. Please try again.
';
+ hideLoadingOverlay();
+ showStatus('File processing failed', 'fa-exclamation-triangle');
}
}
@@ -1273,9 +1710,13 @@
const text = e.target.result;
setDocumentContent(text);
showToast(`Loaded ${formatFileSize(file.size)} text file`, 'success');
+ hideLoadingOverlay();
+ showStatus(`File loaded: ${file.name}`, 'fa-file-alt');
};
reader.onerror = () => {
showToast('Error reading text file', 'error');
+ hideLoadingOverlay();
+ showStatus('File loading failed', 'fa-exclamation-triangle');
};
reader.readAsText(file, 'UTF-8');
}
@@ -1284,7 +1725,7 @@
function readPDFFile(file) {
if (typeof pdfjsLib === 'undefined') {
showToast('PDF.js not loaded. Please refresh the page and try again.', 'error');
- documentContent.innerHTML = '
PDF.js library not available.
';
+ hideLoadingOverlay();
return;
}
@@ -1307,16 +1748,7 @@
return page.getTextContent().then(textContent => {
pagesProcessed++;
const progress = (pagesProcessed / numPages) * 100;
- documentContent.innerHTML = `
-
-
-
Processing PDF...
-
-
${pagesProcessed}/${numPages} pages
-
- `;
+ loadingMessage.textContent = `Processing page ${pagesProcessed}/${numPages}...`;
return textContent.items.map(item => {
if (item.str) {
@@ -1333,15 +1765,21 @@
setDocumentContent(text);
URL.revokeObjectURL(fileURL);
showToast(`Loaded PDF with ${numPages} pages`, 'success');
+ hideLoadingOverlay();
+ showStatus(`PDF loaded: ${numPages} pages`, 'fa-file-pdf');
}).catch(error => {
console.error('Error extracting PDF text:', error);
showToast('Failed to extract text from PDF', 'error');
URL.revokeObjectURL(fileURL);
+ hideLoadingOverlay();
+ showStatus('PDF processing failed', 'fa-exclamation-triangle');
});
}).catch(error => {
console.error('Error loading PDF:', error);
showToast('Failed to load PDF file: ' + error.message, 'error');
URL.revokeObjectURL(fileURL);
+ hideLoadingOverlay();
+ showStatus('PDF loading failed', 'fa-exclamation-triangle');
});
}
@@ -1349,7 +1787,7 @@
function readWordDocument(file) {
if (typeof mammoth === 'undefined') {
showToast('Mammoth.js not loaded. Please refresh the page and try again.', 'error');
- documentContent.innerHTML = '
Mammoth.js library not available for Word documents.
';
+ hideLoadingOverlay();
return;
}
@@ -1367,9 +1805,10 @@
if (result.value && result.value.trim()) {
setDocumentContent(result.value);
showToast(`Loaded Word document: ${file.name}`, 'success');
+ showStatus(`Word document loaded: ${file.name}`, 'fa-file-word');
} else {
showToast('No text content found in Word document', 'error');
- documentContent.innerHTML = '
No readable text found in document.
';
+ showStatus('No readable text found', 'fa-exclamation-triangle');
}
if (result.messages && result.messages.length > 0) {
@@ -1386,12 +1825,16 @@
}
showToast(errorMessage, 'error');
- documentContent.innerHTML = '
Error loading Word document. Please try a different format.
';
+ showStatus('Word document processing failed', 'fa-exclamation-triangle');
+ } finally {
+ hideLoadingOverlay();
}
};
reader.onerror = () => {
showToast('Error reading file', 'error');
+ hideLoadingOverlay();
+ showStatus('File reading failed', 'fa-exclamation-triangle');
};
reader.readAsArrayBuffer(file);
@@ -1413,9 +1856,14 @@
rtfText = rtfText.trim();
setDocumentContent(rtfText);
+ showToast(`Loaded RTF file: ${file.name}`, 'success');
+ hideLoadingOverlay();
+ showStatus(`RTF loaded: ${file.name}`, 'fa-file-alt');
};
reader.onerror = () => {
showToast('Error reading RTF file', 'error');
+ hideLoadingOverlay();
+ showStatus('RTF reading failed', 'fa-exclamation-triangle');
};
reader.readAsText(file);
}
@@ -1441,9 +1889,14 @@
text = text.replace(/\n{3,}/g, '\n\n'); // Multiple newlines
setDocumentContent(text);
+ showToast(`Loaded Markdown file: ${file.name}`, 'success');
+ hideLoadingOverlay();
+ showStatus(`Markdown loaded: ${file.name}`, 'fa-file-alt');
};
reader.onerror = () => {
showToast('Error reading Markdown file', 'error');
+ hideLoadingOverlay();
+ showStatus('Markdown reading failed', 'fa-exclamation-triangle');
};
reader.readAsText(file);
}
@@ -1470,12 +1923,19 @@
jsonToText(jsonData);
setDocumentContent(text);
+ showToast(`Loaded JSON file: ${file.name}`, 'success');
+ hideLoadingOverlay();
+ showStatus(`JSON loaded: ${file.name}`, 'fa-file-alt');
} catch (error) {
showToast('Invalid JSON file', 'error');
+ hideLoadingOverlay();
+ showStatus('Invalid JSON file', 'fa-exclamation-triangle');
}
};
reader.onerror = () => {
showToast('Error reading JSON file', 'error');
+ hideLoadingOverlay();
+ showStatus('JSON reading failed', 'fa-exclamation-triangle');
};
reader.readAsText(file);
}
@@ -1499,9 +1959,14 @@
text = text.trim();
setDocumentContent(text);
+ showToast(`Loaded HTML file: ${file.name}`, 'success');
+ hideLoadingOverlay();
+ showStatus(`HTML loaded: ${file.name}`, 'fa-file-alt');
};
reader.onerror = () => {
showToast('Error reading HTML file', 'error');
+ hideLoadingOverlay();
+ showStatus('HTML reading failed', 'fa-exclamation-triangle');
};
reader.readAsText(file);
}
@@ -1533,12 +1998,19 @@
text = text.trim();
setDocumentContent(text);
+ showToast(`Loaded XML file: ${file.name}`, 'success');
+ hideLoadingOverlay();
+ showStatus(`XML loaded: ${file.name}`, 'fa-file-alt');
} catch (error) {
showToast('Error parsing XML file', 'error');
+ hideLoadingOverlay();
+ showStatus('XML parsing failed', 'fa-exclamation-triangle');
}
};
reader.onerror = () => {
showToast('Error reading XML file', 'error');
+ hideLoadingOverlay();
+ showStatus('XML reading failed', 'fa-exclamation-triangle');
};
reader.readAsText(file);
}
@@ -1566,80 +2038,159 @@
setDocumentContent(text);
showToast('CSV file loaded. Showing data summary for TTS.', 'success');
+ hideLoadingOverlay();
+ showStatus(`CSV loaded: ${file.name}`, 'fa-file-alt');
};
reader.onerror = () => {
showToast('Error reading CSV file', 'error');
+ hideLoadingOverlay();
+ showStatus('CSV reading failed', 'fa-exclamation-triangle');
};
reader.readAsText(file);
}
- // Set document content
+ // Set document content with enhanced features
function setDocumentContent(text) {
currentText = text;
- documentContent.innerHTML = text.split('\n').map(line => `
${line || ' '}
`).join('');
- charCount.textContent = `${text.length} characters`;
+
+ if (!text.trim()) {
+ documentContent.innerHTML = '
Your document content will appear here...
';
+ 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');
+ 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');
}
- // Playback controls
- playBtn.addEventListener('click', async () => {
- if (!selectedVoice) {
- showToast('Please select a voice first', 'error');
- return;
+ // 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');
+ }
}
- if (!currentText.trim()) {
- showToast('Please add some text to read', 'error');
+ // 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) ?
+ '
Remove Bookmark' :
+ '
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;
}
- if (isPaused) {
- resumePlayback();
+ 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 {
- await startPlayback();
+ // 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) ?
+ '
Remove Bookmark' :
+ '
Add Bookmark';
});
- pauseBtn.addEventListener('click', () => {
- if (isPlaying && !isPaused) {
- pausePlayback();
+ // Enhanced synthesis with progress tracking
+ async function performSynthesis() {
+ if (!selectedVoice) {
+ showToast('Please select a voice first', 'error');
+ return null;
}
- });
-
- stopBtn.addEventListener('click', () => {
- stopPlayback();
- });
-
- // Playback functions with enhanced progress tracking
- async function startPlayback() {
+
+ if (!currentText.trim()) {
+ showToast('Please add some text to synthesize', 'error');
+ return null;
+ }
+
+ isSynthesizing = true;
+ updatePlaybackButtons();
+
try {
- isPlaying = true;
- isPaused = false;
- updatePlaybackButtons();
-
- showToast('Generating speech...', 'success', 1000);
+ showLoadingOverlay('Generating Speech', 'Preparing text chunks...');
+ synthesisProgress.classList.remove('hidden');
const chunks = splitTextIntoChunks(currentText, MAX_CHUNK_SIZE);
-
- if (chunks.length > 1) {
- showToast(`Processing ${chunks.length} chunks...`, 'success', 2000);
- }
-
let allAudioData = [];
- // Show synthesis progress
- showSynthesisProgress(0);
+ showStatus(`Synthesizing ${chunks.length} chunks...`, 'fa-magic', 'persistent');
for (let i = 0; i < chunks.length; i++) {
- if (!isPlaying || isPaused) break;
-
const progress = (i / chunks.length) * 100;
- showSynthesisProgress(progress);
+ synthesisProgressBar.style.width = progress + '%';
+ synthesisPercentage.textContent = Math.round(progress) + '%';
+ loadingMessage.textContent = `Processing chunk ${i + 1} of ${chunks.length}...`;
const audioData = await synthesizeSpeech(
chunks[i],
@@ -1652,39 +2203,124 @@
allAudioData.push(audioData);
}
- hideSynthesisProgress();
+ synthesisProgressBar.style.width = '100%';
+ synthesisPercentage.textContent = '100%';
- if (allAudioData.length > 0 && isPlaying) {
- await combineAndPlayAudio(allAudioData);
+ if (allAudioData.length > 0) {
+ const combinedAudio = await combineAudioChunks(allAudioData);
+ currentAudioBlob = combinedAudio;
+
+ showToast('Speech synthesis completed successfully', 'success');
+ showStatus('Speech ready to play', 'fa-check');
+
+ return combinedAudio;
}
} catch (error) {
- hideSynthesisProgress();
- console.error('Error during playback:', error);
- showToast('Failed to play text: ' + error.message, 'error');
- stopPlayback();
+ console.error('Error during synthesis:', error);
+ showToast('Failed to generate speech: ' + error.message, 'error');
+ showStatus('Synthesis failed', 'fa-exclamation-triangle');
+ return null;
+ } finally {
+ isSynthesizing = false;
+ updatePlaybackButtons();
+ hideLoadingOverlay();
+ synthesisProgress.classList.add('hidden');
}
}
- async function combineAndPlayAudio(audioDataArray) {
- try {
- const firstAudioData = audioDataArray[0];
-
- const byteCharacters = atob(firstAudioData);
+ // Combine audio chunks into a single blob
+ async function combineAudioChunks(audioDataArray) {
+ if (audioDataArray.length === 1) {
+ const audioData = audioDataArray[0];
+ const byteCharacters = atob(audioData);
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' });
-
+ return new Blob([byteArray], { type: 'audio/mpeg' });
+ }
+
+ // For multiple chunks, use the first one as primary
+ // (In a real implementation, you might want to properly concatenate audio)
+ const firstAudioData = audioDataArray[0];
+ 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);
+ return new Blob([byteArray], { type: 'audio/mpeg' });
+ }
+
+ // Playback controls
+ synthesizeBtn.addEventListener('click', async () => {
+ const audioBlob = await performSynthesis();
+ if (audioBlob) {
downloadBtn.disabled = false;
+ saveToLibraryBtn.disabled = false;
+ }
+ });
+
+ playBtn.addEventListener('click', async () => {
+ if (!currentAudioBlob && !isSynthesizing) {
+ // No audio generated yet, synthesize first
+ showToast('Generating speech first...', 'info');
+ const audioBlob = await performSynthesis();
+ if (!audioBlob) return;
+ }
+
+ if (isPaused) {
+ resumePlayback();
+ } else if (!isPlaying && currentAudioBlob) {
+ await startPlayback();
+ } else if (isPlaying) {
+ pausePlayback();
+ }
+ });
+
+ stopBtn.addEventListener('click', () => {
+ stopPlayback();
+ });
+
+ downloadBtn.addEventListener('click', () => {
+ downloadAudio();
+ });
+
+ // Enhanced playback functions
+ async function startPlayback() {
+ if (!currentAudioBlob || !audioContext) {
+ showToast('No audio available to play', 'error');
+ return;
+ }
+
+ try {
+ const arrayBuffer = await currentAudioBlob.arrayBuffer();
+ audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
+
+ audioSource = audioContext.createBufferSource();
+ audioSource.buffer = audioBuffer;
+ audioSource.connect(audioContext.destination);
+
+ isPlaying = true;
+ isPaused = false;
+ updatePlaybackButtons();
+
+ audioSource.start(0);
+ startTime = audioContext.currentTime;
- await playAudioData(firstAudioData);
+ startProgressTracking();
+ showStatus('Playing audio', 'fa-play', 'persistent');
+ audioSource.onended = () => {
+ if (isPlaying && !isPaused) {
+ stopPlayback();
+ }
+ };
} catch (error) {
- console.error('Error combining audio:', error);
- showToast('Failed to process audio', 'error');
+ console.error('Error starting playback:', error);
+ showToast('Failed to play audio: ' + error.message, 'error');
}
}
@@ -1694,6 +2330,8 @@
audioSource.stop();
isPaused = true;
updatePlaybackButtons();
+ showStatus('Audio paused', 'fa-pause');
+ showToast('Playback paused', 'info', 1000);
}
}
@@ -1712,6 +2350,8 @@
updatePlaybackButtons();
startProgressTracking();
+ showStatus('Playing audio', 'fa-play', 'persistent');
+ showToast('Playback resumed', 'info', 1000);
audioSource.onended = () => {
if (isPlaying && !isPaused) {
@@ -1734,26 +2374,39 @@
updatePlaybackButtons();
updateProgress(0);
+ // Clear reading highlight
documentContent.querySelectorAll('.reading-highlight').forEach(el => {
el.classList.remove('reading-highlight');
});
+
+ showStatus('Audio stopped', 'fa-stop');
+ showToast('Playback stopped', 'info', 1000);
}
function updatePlaybackButtons() {
- playBtn.disabled = false;
- pauseBtn.disabled = !isPlaying || isPaused;
- stopBtn.disabled = !isPlaying;
- downloadBtn.disabled = !currentAudioBlob;
+ // Synthesize button
+ synthesizeBtn.disabled = isSynthesizing || !currentText.trim() || !selectedVoice;
+ // Play/Pause button
if (isPlaying && !isPaused) {
- playBtn.innerHTML = '
';
+ playBtn.innerHTML = '
';
+ playBtn.setAttribute('aria-label', 'Pause audio');
+ playBtn.title = 'Pause';
} else {
- playBtn.innerHTML = '
';
+ playBtn.innerHTML = '
';
+ playBtn.setAttribute('aria-label', 'Play audio');
+ playBtn.title = 'Play';
}
+
+ playBtn.disabled = (!currentAudioBlob && !isSynthesizing) && !isPlaying;
+ stopBtn.disabled = !isPlaying;
+ downloadBtn.disabled = !currentAudioBlob;
+ saveToLibraryBtn.disabled = !currentAudioBlob;
}
function updateProgress(progress) {
progressBar.style.width = (progress * 100) + '%';
+ progressBar.setAttribute('aria-valuenow', Math.round(progress * 100));
const currentSeconds = Math.floor(progress * estimatedDuration);
const totalSeconds = Math.floor(estimatedDuration);
@@ -1774,7 +2427,23 @@
if (currentChunk) {
chunks.push(currentChunk.trim());
}
- currentChunk = sentence;
+
+ // Handle very long sentences that exceed chunk size
+ 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;
+ }
}
}
@@ -1785,10 +2454,257 @@
return chunks;
}
+ // 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');
+ }
+ }
+
+ // Audio Library Management
+ libraryBtn.addEventListener('click', () => {
+ openLibraryModal();
+ });
+
+ closeLibraryModal.addEventListener('click', () => {
+ closeLibrary();
+ });
+
+ saveToLibraryBtn.addEventListener('click', () => {
+ saveCurrentAudioToLibrary();
+ });
+
+ 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();
+
+ // Focus trap
+ const focusableElements = libraryModal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
+ if (focusableElements.length > 0) {
+ focusableElements[0].focus();
+ }
+ }
+
+ function closeLibrary() {
+ libraryModal.classList.add('hidden');
+ libraryModal.setAttribute('aria-hidden', 'true');
+ }
+
+ function saveCurrentAudioToLibrary() {
+ if (!currentAudioBlob || !currentText) {
+ showToast('No audio or text available to save', 'error');
+ return;
+ }
+
+ const libraryItem = {
+ id: Date.now(),
+ title: currentText.substring(0, 50) + (currentText.length > 50 ? '...' : ''),
+ text: currentText,
+ voice: selectedVoice ? selectedVoice.name : 'Unknown',
+ settings: {
+ rate: parseFloat(rateSelect.value),
+ pitch: parseFloat(pitchSelect.value),
+ model: modelSelect.value
+ },
+ date: new Date().toISOString(),
+ size: currentAudioBlob.size
+ };
+
+ // Convert blob to base64 for storage
+ const reader = new FileReader();
+ reader.onload = () => {
+ libraryItem.audioData = reader.result;
+ audioLibrary.push(libraryItem);
+
+ // Limit library size (keep last 50 items)
+ if (audioLibrary.length > 50) {
+ audioLibrary = audioLibrary.slice(-50);
+ }
+
+ localStorage.setItem('audioLibrary', JSON.stringify(audioLibrary.map(item => ({
+ ...item,
+ audioData: null // Don't store large audio data in localStorage
+ }))));
+
+ updateLibraryView();
+ showToast('Audio saved to library', 'success');
+ };
+ reader.readAsDataURL(currentAudioBlob);
+ }
+
+ function updateLibraryView() {
+ libraryCount.textContent = `${audioLibrary.length} items`;
+
+ if (audioLibrary.length === 0) {
+ libraryContent.innerHTML = `
+
+
+
No audio files saved yet
+
Generate and save audio to build your library
+
+ `;
+ return;
+ }
+
+ libraryContent.innerHTML = '';
+
+ audioLibrary.slice().reverse().forEach(item => {
+ const itemEl = document.createElement('div');
+ itemEl.className = 'library-item';
+ itemEl.innerHTML = `
+
+
${item.title}
+
+
+
+
+
+
+
+
+
+
+
+ ${item.voice}
+ ${new Date(item.date).toLocaleDateString()}
+
+
+
+ ${item.settings.rate}x speed, ${item.settings.pitch > 0 ? '+' : ''}${item.settings.pitch} pitch
+
+
+
+
${item.text.substring(0, 100)}...
+ `;
+
+ libraryContent.appendChild(itemEl);
+ });
+
+ // Add event listeners to library items
+ libraryContent.addEventListener('click', (e) => {
+ const loadBtn = e.target.closest('.load-library-item');
+ const deleteBtn = e.target.closest('.delete-library-item');
+
+ if (loadBtn) {
+ const itemId = parseInt(loadBtn.dataset.id);
+ loadLibraryItem(itemId);
+ } else if (deleteBtn) {
+ const itemId = parseInt(deleteBtn.dataset.id);
+ deleteLibraryItem(itemId);
+ }
+ });
+ }
+
+ 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 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 clearAudioLibrary() {
+ audioLibrary = [];
+ localStorage.setItem('audioLibrary', JSON.stringify([]));
+ updateLibraryView();
+ showToast('Audio library cleared', 'success');
+ }
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', (e) => {
+ // Ctrl+Space or Cmd+Space to play/pause
+ if ((e.ctrlKey || e.metaKey) && e.code === 'Space') {
+ e.preventDefault();
+ if (isPlaying) {
+ if (isPaused) {
+ resumePlayback();
+ } else {
+ pausePlayback();
+ }
+ } else if (currentAudioBlob) {
+ startPlayback();
+ }
+ }
+
+ // Ctrl+S or Cmd+S to synthesize
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+ e.preventDefault();
+ if (!isSynthesizing) {
+ performSynthesis();
+ }
+ }
+
+ // Escape to close modals
+ if (e.key === 'Escape') {
+ if (!libraryModal.classList.contains('hidden')) {
+ closeLibrary();
+ }
+ }
+ });
+
// Event listeners
refreshVoicesBtn.addEventListener('click', loadVoices);
languageSelect.addEventListener('change', loadVoices);
+ // Modal click outside to close
+ libraryModal.addEventListener('click', (e) => {
+ if (e.target === libraryModal) {
+ closeLibrary();
+ }
+ });
+
// Initialize the application
init();
});