Toowired commited on
Commit
f570f36
·
verified ·
1 Parent(s): f181983

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +285 -1194
index.html CHANGED
@@ -100,10 +100,6 @@
100
  from { transform: translateX(100%); opacity: 0; }
101
  to { transform: translateX(0); opacity: 1; }
102
  }
103
- @keyframes slideOut {
104
- from { transform: translateX(0); opacity: 1; }
105
- to { transform: translateX(100%); opacity: 0; }
106
- }
107
 
108
  /* Progress indicator styles */
109
  #progressContainer {
@@ -174,7 +170,7 @@
174
  <ul class="mt-1 list-disc list-inside space-y-1">
175
  <li>Make sure billing is enabled for your project</li>
176
  <li>Copy ONLY the API key (no extra text)</li>
177
- <li>If restricted, allow this domain: ${window.location.hostname}</li>
178
  </ul>
179
  </div>
180
  </div>
@@ -261,6 +257,11 @@
261
  </button>
262
  </div>
263
  </div>
 
 
 
 
 
264
 
265
  <div id="voiceGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
266
  <!-- Voices will be loaded dynamically -->
@@ -299,10 +300,10 @@
299
  <div>
300
  <label for="modelSelect" class="block text-sm font-medium text-gray-700 mb-1">Voice Model</label>
301
  <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">
302
- <option value="studio">Studio Quality - $160.00/1M chars (Premium voices: US-O, US-Q)</option>
303
- <option value="wavenet" selected>WaveNet - $16.00/1M chars (High quality: US-A through US-J)</option>
304
- <option value="neural2">Neural2 - $16.00/1M chars (Enhanced Neural: US-A through US-J)</option>
305
- <option value="standard">Standard - $4.00/1M chars (Basic quality)</option>
306
  </select>
307
  </div>
308
  </div>
@@ -347,10 +348,11 @@
347
 
348
  <script>
349
  document.addEventListener('DOMContentLoaded', () => {
350
- // Global state variables (properly initialized)
351
  let apiKey = localStorage.getItem('googleTTSApiKey') || '';
352
  let currentText = '';
353
  let selectedVoice = null;
 
354
  let isPlaying = false;
355
  let isPaused = false;
356
  let audioContext = null;
@@ -373,7 +375,6 @@
373
  // Global error handler
374
  window.addEventListener('error', (e) => {
375
  console.error('Global error caught:', e.error);
376
- console.error('Error stack:', e.error?.stack);
377
  });
378
 
379
  // Global unhandled promise rejection handler
@@ -392,6 +393,7 @@
392
  const documentContent = document.getElementById('documentContent');
393
  const charCount = document.getElementById('charCount');
394
  const voiceGrid = document.getElementById('voiceGrid');
 
395
  const refreshVoicesBtn = document.getElementById('refreshVoicesBtn');
396
  const languageSelect = document.getElementById('languageSelect');
397
  const rateSelect = document.getElementById('rateSelect');
@@ -411,15 +413,6 @@
411
  // Dark mode elements
412
  const darkModeToggle = document.getElementById('darkModeToggle');
413
 
414
- // Utility function for file size formatting
415
- function formatFileSize(bytes) {
416
- if (bytes === 0) return '0 Bytes';
417
- const k = 1024;
418
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
419
- const i = Math.floor(Math.log(bytes) / Math.log(k));
420
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
421
- }
422
-
423
  // Initialize function
424
  function init() {
425
  // Load saved API key
@@ -461,121 +454,14 @@
461
 
462
  // Event listeners to update cost estimates
463
  modelSelect.addEventListener('change', (e) => {
464
- modelSelect.dataset.currentValue = e.target.value;
465
  updateCostEstimator();
466
-
467
- // Auto-select appropriate voice based on model
468
  autoSelectVoiceForModel(e.target.value);
469
-
470
- // Show a toast indicating voice change
471
- if (selectedVoice) {
472
- showToast(`Voice changed to ${selectedVoice.name} for ${e.target.value} model`, 'success', 2000);
473
- }
474
  });
475
 
476
- // Update cost estimator when settings change
477
  rateSelect.addEventListener('change', updateCostEstimator);
478
  pitchSelect.addEventListener('change', updateCostEstimator);
479
 
480
- // Initialize cost breakdown
481
- document.getElementById('showCostBreakdown').addEventListener('click', showCostBreakdownModal);
482
-
483
- // Set default model value
484
- modelSelect.dataset.currentValue = 'wavenet';
485
- }
486
-
487
- // Dark Mode Implementation
488
- function initDarkMode() {
489
- const savedTheme = localStorage.getItem('theme');
490
- const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
491
-
492
- if (savedTheme) {
493
- document.documentElement.setAttribute('data-theme', savedTheme);
494
- updateDarkModeIcon(savedTheme === 'dark');
495
- } else if (systemPrefersDark) {
496
- document.documentElement.setAttribute('data-theme', 'dark');
497
- updateDarkModeIcon(true);
498
- } else {
499
- document.documentElement.setAttribute('data-theme', 'light');
500
- updateDarkModeIcon(false);
501
- }
502
-
503
- darkModeToggle.addEventListener('click', toggleDarkMode);
504
- }
505
-
506
- function toggleDarkMode() {
507
- const currentTheme = document.documentElement.getAttribute('data-theme');
508
- const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
509
-
510
- document.documentElement.setAttribute('data-theme', newTheme);
511
- localStorage.setItem('theme', newTheme);
512
- updateDarkModeIcon(newTheme === 'dark');
513
- }
514
-
515
- function updateDarkModeIcon(isDark) {
516
- const icon = darkModeToggle.querySelector('i');
517
- icon.className = isDark ? 'fas fa-sun text-xl' : 'fas fa-moon text-xl';
518
- }
519
-
520
- // Progress indicator initialization
521
- function initProgressIndicator() {
522
- progressContainer.addEventListener('click', (e) => {
523
- if (!audioSource || !audioBuffer) return;
524
-
525
- const rect = progressContainer.getBoundingClientRect();
526
- const clickX = e.clientX - rect.left;
527
- const percentage = clickX / rect.width;
528
- const newTime = percentage * audioBuffer.duration;
529
-
530
- seekToTime(newTime);
531
- });
532
- }
533
-
534
- function seekToTime(time) {
535
- if (!audioSource || !audioBuffer) return;
536
-
537
- audioSource.stop();
538
-
539
- audioSource = audioContext.createBufferSource();
540
- audioSource.buffer = audioBuffer;
541
- audioSource.connect(audioContext.destination);
542
-
543
- startTime = audioContext.currentTime - time;
544
- audioSource.start(0, time);
545
-
546
- audioSource.onended = () => {
547
- if (isPlaying && !isPaused) {
548
- stopPlayback();
549
- }
550
- };
551
- }
552
-
553
- // Download feature initialization
554
- function initDownloadFeature() {
555
- downloadBtn.addEventListener('click', downloadAudio);
556
- }
557
-
558
- async function downloadAudio() {
559
- if (!currentAudioBlob) {
560
- showToast('No audio available to download', 'error');
561
- return;
562
- }
563
-
564
- try {
565
- const url = URL.createObjectURL(currentAudioBlob);
566
- const a = document.createElement('a');
567
- a.href = url;
568
- a.download = `tts-audio-${new Date().toISOString().split('T')[0]}.mp3`;
569
- document.body.appendChild(a);
570
- a.click();
571
- document.body.removeChild(a);
572
- URL.revokeObjectURL(url);
573
-
574
- showToast('Audio downloaded successfully', 'success');
575
- } catch (error) {
576
- console.error('Download error:', error);
577
- showToast('Failed to download audio', 'error');
578
- }
579
  }
580
 
581
  // Voice type and pricing functions
@@ -642,6 +528,37 @@
642
  }
643
  }
644
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
645
  // Cost breakdown modal
646
  function showCostBreakdownModal() {
647
  const modal = document.createElement('div');
@@ -725,6 +642,100 @@
725
  }
726
  }
727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
728
  // Toast notifications
729
  function showToast(message, type = 'success', duration = 3000) {
730
  const toast = document.createElement('div');
@@ -738,6 +749,15 @@
738
  }, duration);
739
  }
740
 
 
 
 
 
 
 
 
 
 
741
  // API Key Management
742
  saveKeyBtn.addEventListener('click', async () => {
743
  const key = apiKeyInput.value ? apiKeyInput.value.trim() : '';
@@ -806,1083 +826,126 @@
806
  <p class="text-gray-500 mt-2">Loading Google TTS voices...</p>
807
  </div>
808
  `;
809
-
810
- try {
811
- const languageCode = languageSelect.value;
812
- const response = await fetch(`https://texttospeech.googleapis.com/v1/voices?languageCode=${languageCode}`, {
813
- method: 'GET',
814
- headers: {
815
- 'X-Goog-Api-Key': apiKey.trim(),
816
- 'Accept': 'application/json',
817
- },
818
- mode: 'cors',
819
- cache: 'no-cache'
820
- });
821
-
822
- if (!response.ok) {
823
- throw new Error(`API error (${response.status}): Failed to load voices`);
824
- }
825
-
826
- const data = await response.json();
827
-
828
- if (!data || !data.voices) {
829
- throw new Error('Invalid response from Google TTS API - no voices returned');
830
- }
831
-
832
- renderVoiceOptions(data.voices);
833
- } catch (error) {
834
- console.error('Error loading voices:', error);
835
- voiceGrid.innerHTML = `
836
- <div class="col-span-full text-center py-8">
837
- <i class="fas fa-exclamation-triangle text-red-400 text-2xl"></i>
838
- <p class="text-gray-500 mt-2">Failed to load voices</p>
839
- <p class="text-red-500 text-sm mt-1 max-w-md mx-auto">${error.message}</p>
840
- <button id="retryVoices" class="mt-4 bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded text-sm">
841
- <i class="fas fa-sync-alt mr-1"></i> Retry
842
- </button>
843
- </div>
844
- `;
845
-
846
- document.getElementById('retryVoices')?.addEventListener('click', loadVoices);
847
- }
848
- }
849
-
850
- function renderVoiceOptions(voices) {
851
- voiceGrid.innerHTML = '';
852
-
853
- if (!voices || voices.length === 0) {
854
- voiceGrid.innerHTML = `
855
- <div class="col-span-full text-center py-8">
856
- <i class="fas fa-microphone-slash text-gray-400 text-2xl"></i>
857
- <p class="text-gray-500 mt-2">No voices available for selected language</p>
858
- </div>
859
- `;
860
- return;
861
- }
862
-
863
- // Filter for natural HD voices
864
- const naturalVoices = voices.filter(voice =>
865
- voice.name.includes('Wavenet') ||
866
- voice.name.includes('Studio') ||
867
- voice.name.includes('Neural2')
868
- );
869
-
870
- naturalVoices.sort((a, b) => a.name.localeCompare(b.name));
871
-
872
- naturalVoices.forEach(voice => {
873
- const voiceCard = document.createElement('div');
874
- voiceCard.className = 'voice-card bg-white p-4 rounded-lg cursor-pointer relative';
875
- voiceCard.dataset.voiceName = voice.name;
876
-
877
- let genderIcon = 'fa-user';
878
- let genderColor = 'text-gray-500';
879
- if (voice.ssmlGender === 'FEMALE') {
880
- genderIcon = 'fa-venus';
881
- genderColor = 'text-pink-500';
882
- } else if (voice.ssmlGender === 'MALE') {
883
- genderIcon = 'fa-mars';
884
- genderColor = 'text-blue-500';
885
- }
886
-
887
- let qualityBadge = '';
888
- if (voice.name.includes('Studio')) {
889
- qualityBadge = '<div class="absolute top-2 right-2 bg-purple-500 text-white text-xs px-2 py-1 rounded">Studio</div>';
890
- } else if (voice.name.includes('Wavenet')) {
891
- qualityBadge = '<div class="absolute top-2 right-2 bg-green-500 text-white text-xs px-2 py-1 rounded">WaveNet</div>';
892
- } else if (voice.name.includes('Neural2')) {
893
- qualityBadge = '<div class="absolute top-2 right-2 bg-blue-500 text-white text-xs px-2 py-1 rounded">Neural2</div>';
894
- }
895
-
896
- voiceCard.innerHTML = `
897
- <div class="flex items-start">
898
- <div class="mr-3 ${genderColor}">
899
- <i class="fas ${genderIcon} text-xl"></i>
900
- </div>
901
- <div class="flex-1">
902
- <h3 class="font-medium text-gray-800">${voice.name.replace('en-', '').replace('Wavenet', '').replace('Neural2', '').replace('Studio', '').trim()}</h3>
903
- <p class="text-sm text-gray-600">${voice.languageCodes[0]}</p>
904
- <div class="mt-2 flex items-center">
905
- <button class="play-sample text-xs bg-gray-100 hover:bg-gray-200 text-gray-700 py-1 px-2 rounded">
906
- <i class="fas fa-play mr-1"></i> Sample
907
- </button>
908
- </div>
909
- </div>
910
- <div class="ml-2 text-blue-500 hidden selected-icon">
911
- <i class="fas fa-check-circle"></i>
912
- </div>
913
- </div>
914
- ${qualityBadge}
915
- `;
916
-
917
- voiceCard.addEventListener('click', () => {
918
- document.querySelectorAll('.voice-card').forEach(card => {
919
- card.classList.remove('selected');
920
- card.querySelector('.selected-icon').classList.add('hidden');
921
- });
922
-
923
- voiceCard.classList.add('selected');
924
- voiceCard.querySelector('.selected-icon').classList.remove('hidden');
925
- selectedVoice = voice;
926
-
927
- updateCostEstimator();
928
- });
929
-
930
- const playSampleBtn = voiceCard.querySelector('.play-sample');
931
- playSampleBtn.addEventListener('click', (e) => {
932
- e.stopPropagation();
933
- playSample(voice, "Hello, this is a sample of my voice. I can read your documents with natural sounding speech.");
934
- });
935
-
936
- voiceGrid.appendChild(voiceCard);
937
- });
938
-
939
- if (naturalVoices.length > 0) {
940
- const firstCard = voiceGrid.querySelector('.voice-card');
941
- if (firstCard) {
942
- firstCard.click();
943
- updateCostEstimator();
944
- }
945
- }
946
-
947
- // Initialize the model dropdown with costs
948
- if (currentText.trim()) {
949
- updateModelDropdownCosts();
950
- }
951
- }
952
-
953
- // Play a voice sample
954
- async function playSample(voice, text) {
955
- if (!apiKey) {
956
- showToast('Please enter your API key first', 'error');
957
- return;
958
- }
959
-
960
- if (isPlaying) {
961
- stopPlayback();
962
- }
963
-
964
- try {
965
- const audioData = await synthesizeSpeech(text, voice, parseFloat(rateSelect.value), parseFloat(pitchSelect.value), modelSelect.value);
966
- playAudioData(audioData);
967
- } catch (error) {
968
- console.error('Error playing sample:', error);
969
- showToast('Failed to play sample: ' + error.message, 'error');
970
- }
971
- }
972
-
973
- // Enhanced synthesize speech with caching and budget tracking
974
- async function synthesizeSpeech(text, voice, rate = 1, pitch = 0, model = 'standard') {
975
- if (!apiKey) {
976
- throw new Error('API key not provided');
977
- }
978
-
979
- if (!text || !text.trim()) {
980
- throw new Error('No text provided for synthesis');
981
- }
982
-
983
- const cleanApiKey = apiKey.trim();
984
- const settings = { rate, pitch, model };
985
-
986
- // Check cache first
987
- const cachedAudio = await ttsCache.get(text, voice, settings);
988
- if (cachedAudio) {
989
- console.log('Cache hit for text chunk');
990
- return cachedAudio;
991
- }
992
-
993
- let voiceConfig = {
994
- languageCode: voice.languageCodes[0],
995
- name: voice.name,
996
- ssmlGender: voice.ssmlGender
997
- };
998
-
999
- let audioConfig = {
1000
- audioEncoding: 'MP3',
1001
- speakingRate: Math.max(0.25, Math.min(4.0, rate)),
1002
- pitch: Math.max(-20.0, Math.min(20.0, pitch)),
1003
- };
1004
-
1005
- if (model === 'studio' && voice.name.includes('Studio')) {
1006
- audioConfig.effectsProfileId = ['telephony-class-application'];
1007
- }
1008
-
1009
- const request = {
1010
- input: { text: text.trim() },
1011
- voice: voiceConfig,
1012
- audioConfig: audioConfig
1013
- };
1014
-
1015
- try {
1016
- let response;
1017
- try {
1018
- response = await fetch(`https://texttospeech.googleapis.com/v1/text:synthesize`, {
1019
- method: 'POST',
1020
- headers: {
1021
- 'X-Goog-Api-Key': cleanApiKey,
1022
- 'Content-Type': 'application/json',
1023
- 'Accept': 'application/json',
1024
- },
1025
- mode: 'cors',
1026
- cache: 'no-cache',
1027
- body: JSON.stringify(request)
1028
- });
1029
- } catch (headerError) {
1030
- const url = `https://texttospeech.googleapis.com/v1/text:synthesize?key=${encodeURIComponent(cleanApiKey)}`;
1031
- response = await fetch(url, {
1032
- method: 'POST',
1033
- headers: {
1034
- 'Content-Type': 'application/json',
1035
- 'Accept': 'application/json',
1036
- },
1037
- mode: 'cors',
1038
- cache: 'no-cache',
1039
- body: JSON.stringify(request)
1040
- });
1041
- }
1042
-
1043
- if (!response.ok) {
1044
- let errorMessage = 'Failed to synthesize speech';
1045
- let details = '';
1046
-
1047
- try {
1048
- const errorData = await response.json();
1049
- errorMessage = errorData.error?.message || errorMessage;
1050
- details = errorData.error?.details ? ` (${JSON.stringify(errorData.error.details)})` : '';
1051
- } catch (e) {
1052
- details = ` (HTTP ${response.status}: ${response.statusText})`;
1053
- }
1054
-
1055
- if (response.status === 400) {
1056
- throw new Error('Invalid request parameters. Please check your text and settings.' + details);
1057
- } else if (response.status === 403) {
1058
- throw new Error('Access denied. Please check your API key and quota.' + details);
1059
- } else if (response.status === 429) {
1060
- throw new Error('Rate limit exceeded. Please wait a moment and try again.' + details);
1061
- } else if (response.status >= 500) {
1062
- throw new Error('Google Cloud service error. Please try again later.' + details);
1063
- } else {
1064
- throw new Error(errorMessage + details);
1065
- }
1066
- }
1067
-
1068
- const data = await response.json();
1069
-
1070
- if (!data || !data.audioContent) {
1071
- throw new Error('Invalid response from Google TTS API - no audio content received');
1072
- }
1073
-
1074
- // Calculate cost for this request
1075
- const voiceType = getVoiceTypeAndPricing(voice.name);
1076
- const cost = (text.length / 1000000) * voiceType.rate;
1077
-
1078
- // Add to budget tracking
1079
- budgetManager.addSpending(cost, {
1080
- characters: text.length,
1081
- voice: voice.name,
1082
- model: model
1083
- });
1084
-
1085
- // Cache the result
1086
- await ttsCache.set(text, voice, settings, data.audioContent, cost);
1087
-
1088
- return data.audioContent;
1089
- } catch (error) {
1090
- console.error('Synthesis error:', error);
1091
- throw error;
1092
- }
1093
- }
1094
-
1095
- // Play audio data
1096
- async function playAudioData(audioBase64) {
1097
- if (!audioContext) {
1098
- initAudioContext();
1099
- }
1100
-
1101
- try {
1102
- const audioData = Uint8Array.from(atob(audioBase64), c => c.charCodeAt(0));
1103
- audioBuffer = await audioContext.decodeAudioData(audioData.buffer);
1104
-
1105
- audioSource = audioContext.createBufferSource();
1106
- audioSource.buffer = audioBuffer;
1107
- audioSource.connect(audioContext.destination);
1108
-
1109
- isPlaying = true;
1110
- isPaused = false;
1111
- updatePlaybackButtons();
1112
-
1113
- audioSource.start(0);
1114
- startTime = audioContext.currentTime;
1115
-
1116
- startProgressTracking();
1117
-
1118
- audioSource.onended = () => {
1119
- if (isPlaying && !isPaused) {
1120
- stopPlayback();
1121
- }
1122
- };
1123
- } catch (error) {
1124
- console.error('Error playing audio:', error);
1125
- showToast('Failed to play audio: ' + error.message, 'error');
1126
- }
1127
- }
1128
-
1129
- function startProgressTracking() {
1130
- function updateProgress() {
1131
- if (!isPlaying || isPaused || !audioSource) return;
1132
-
1133
- const currentTime = audioContext.currentTime - startTime;
1134
- const duration = audioBuffer.duration;
1135
- const progress = currentTime / duration;
1136
-
1137
- progressBar.style.width = (progress * 100) + '%';
1138
-
1139
- currentReadingPosition = Math.floor(progress * currentText.length);
1140
- const readingProg = currentReadingPosition / currentText.length;
1141
- readingProgress.style.width = (readingProg * 100) + '%';
1142
-
1143
- currentPositionMarker.style.left = (progress * 100) + '%';
1144
-
1145
- document.getElementById('currentTime').textContent = formatTime(Math.floor(currentTime));
1146
- document.getElementById('totalTime').textContent = formatTime(Math.floor(duration));
1147
-
1148
- updateReadingHighlight();
1149
-
1150
- requestAnimationFrame(updateProgress);
1151
- }
1152
-
1153
- updateProgress();
1154
- }
1155
-
1156
- function updateReadingHighlight() {
1157
- documentContent.querySelectorAll('.reading-highlight').forEach(el => {
1158
- el.classList.remove('reading-highlight');
1159
- });
1160
-
1161
- const paragraphs = documentContent.children;
1162
- let pos = 0;
1163
-
1164
- for (let i = 0; i < paragraphs.length; i++) {
1165
- const p = paragraphs[i];
1166
- const pText = p.textContent;
1167
-
1168
- if (pos <= currentReadingPosition && currentReadingPosition < pos + pText.length) {
1169
- p.classList.add('reading-highlight');
1170
- break;
1171
- }
1172
- pos += pText.length;
1173
- }
1174
- }
1175
-
1176
- function formatTime(seconds) {
1177
- const minutes = Math.floor(seconds / 60);
1178
- const secs = seconds % 60;
1179
- return `${minutes}:${secs.toString().padStart(2, '0')}`;
1180
- }
1181
-
1182
- // File Handling
1183
- uploadBtn.addEventListener('click', () => fileInput.click());
1184
-
1185
- fileInput.addEventListener('change', (e) => {
1186
- if (e.target.files.length > 0) {
1187
- handleFile(e.target.files[0]);
1188
- }
1189
- });
1190
-
1191
- pasteBtn.addEventListener('click', () => {
1192
- documentContent.innerHTML = '<p class="text-gray-500 italic">Press Ctrl+V (Cmd+V on Mac) to paste your text...</p>';
1193
- documentContent.focus();
1194
-
1195
- const pasteHandler = (e) => {
1196
- e.preventDefault();
1197
- const text = (e.clipboardData || window.clipboardData).getData('text');
1198
- if (text) {
1199
- setDocumentContent(text);
1200
- document.removeEventListener('paste', pasteHandler);
1201
- }
1202
- };
1203
-
1204
- document.addEventListener('paste', pasteHandler);
1205
- });
1206
-
1207
- // Drag and Drop
1208
- ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
1209
- dropzone.addEventListener(eventName, preventDefaults, false);
1210
- });
1211
-
1212
- function preventDefaults(e) {
1213
- e.preventDefault();
1214
- e.stopPropagation();
1215
- }
1216
-
1217
- ['dragenter', 'dragover'].forEach(eventName => {
1218
- dropzone.addEventListener(eventName, highlight, false);
1219
- });
1220
-
1221
- ['dragleave', 'drop'].forEach(eventName => {
1222
- dropzone.addEventListener(eventName, unhighlight, false);
1223
- });
1224
-
1225
- function highlight() {
1226
- dropzone.classList.add('active');
1227
- }
1228
-
1229
- function unhighlight() {
1230
- dropzone.classList.remove('active');
1231
- }
1232
-
1233
- dropzone.addEventListener('drop', (e) => {
1234
- const dt = e.dataTransfer;
1235
- const file = dt.files[0];
1236
- if (file) {
1237
- handleFile(file);
1238
- }
1239
- });
1240
-
1241
- // Enhanced file processing
1242
- function handleFile(file) {
1243
- const maxSize = 50 * 1024 * 1024; // 50MB
1244
- if (file.size > maxSize) {
1245
- showToast('File too large. Please upload a file smaller than 50MB.', 'error');
1246
- return;
1247
- }
1248
-
1249
- const fileExtension = file.name.toLowerCase().split('.').pop();
1250
-
1251
- documentContent.innerHTML = `
1252
- <div class="text-center py-8">
1253
- <div class="loading-spinner mx-auto"></div>
1254
- <p class="text-gray-500 mt-2">Processing ${file.name}...</p>
1255
- <p class="text-xs text-gray-400 mt-1">File size: ${formatFileSize(file.size)}</p>
1256
- </div>
1257
- `;
1258
-
1259
- try {
1260
- switch (fileExtension) {
1261
- case 'txt':
1262
- readTextFile(file);
1263
- break;
1264
- case 'pdf':
1265
- readPDFFile(file);
1266
- break;
1267
- case 'doc':
1268
- case 'docx':
1269
- readWordDocument(file);
1270
- break;
1271
- case 'rtf':
1272
- readRTFFile(file);
1273
- break;
1274
- case 'md':
1275
- case 'markdown':
1276
- readMarkdownFile(file);
1277
- break;
1278
- case 'json':
1279
- readJSONFile(file);
1280
- break;
1281
- case 'html':
1282
- case 'htm':
1283
- readHTMLFile(file);
1284
- break;
1285
- case 'xml':
1286
- readXMLFile(file);
1287
- break;
1288
- case 'csv':
1289
- readCSVFile(file);
1290
- break;
1291
- default:
1292
- if (file.type.startsWith('text/')) {
1293
- readTextFile(file);
1294
- } else {
1295
- throw new Error(`Unsupported file type: .${fileExtension}. Please upload a supported document format.`);
1296
- }
1297
- }
1298
- } catch (error) {
1299
- console.error('Error processing file:', error);
1300
- showToast('Error processing file: ' + error.message, 'error');
1301
- documentContent.innerHTML = '<p class="text-gray-500 italic">Error loading document. Please try again.</p>';
1302
- }
1303
- }
1304
-
1305
- // Text file reader
1306
- function readTextFile(file) {
1307
- const reader = new FileReader();
1308
- reader.onload = (e) => {
1309
- const text = e.target.result;
1310
- setDocumentContent(text);
1311
- showToast(`Loaded ${formatFileSize(file.size)} text file`, 'success');
1312
- };
1313
- reader.onerror = () => {
1314
- showToast('Error reading text file', 'error');
1315
- };
1316
- reader.readAsText(file, 'UTF-8');
1317
- }
1318
-
1319
- // PDF file reader
1320
- function readPDFFile(file) {
1321
- if (typeof pdfjsLib === 'undefined') {
1322
- showToast('PDF.js not loaded. Please refresh the page and try again.', 'error');
1323
- documentContent.innerHTML = '<p class="text-gray-500 italic">PDF.js library not available.</p>';
1324
- return;
1325
- }
1326
-
1327
- const fileURL = URL.createObjectURL(file);
1328
-
1329
- const loadingTask = pdfjsLib.getDocument({
1330
- url: fileURL,
1331
- verbosity: 0
1332
- });
1333
-
1334
- loadingTask.promise.then(pdf => {
1335
- let text = '';
1336
- const numPages = pdf.numPages;
1337
- const pagePromises = [];
1338
-
1339
- let pagesProcessed = 0;
1340
-
1341
- for (let i = 1; i <= numPages; i++) {
1342
- pagePromises.push(pdf.getPage(i).then(page => {
1343
- return page.getTextContent().then(textContent => {
1344
- pagesProcessed++;
1345
- const progress = (pagesProcessed / numPages) * 100;
1346
- documentContent.innerHTML = `
1347
- <div class="text-center py-8">
1348
- <div class="loading-spinner mx-auto"></div>
1349
- <p class="text-gray-500 mt-2">Processing PDF...</p>
1350
- <div class="w-32 bg-gray-200 rounded-full h-2 mx-auto mt-3">
1351
- <div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: ${progress}%"></div>
1352
- </div>
1353
- <p class="text-xs text-gray-400 mt-2">${pagesProcessed}/${numPages} pages</p>
1354
- </div>
1355
- `;
1356
-
1357
- return textContent.items.map(item => {
1358
- if (item.str) {
1359
- return item.str;
1360
- }
1361
- return '';
1362
- }).join(' ');
1363
- });
1364
- }));
1365
- }
1366
-
1367
- Promise.all(pagePromises).then(pagesText => {
1368
- text = pagesText.join('\n\n');
1369
- setDocumentContent(text);
1370
- URL.revokeObjectURL(fileURL);
1371
- showToast(`Loaded PDF with ${numPages} pages`, 'success');
1372
- }).catch(error => {
1373
- console.error('Error extracting PDF text:', error);
1374
- showToast('Failed to extract text from PDF', 'error');
1375
- URL.revokeObjectURL(fileURL);
1376
- });
1377
- }).catch(error => {
1378
- console.error('Error loading PDF:', error);
1379
- showToast('Failed to load PDF file: ' + error.message, 'error');
1380
- URL.revokeObjectURL(fileURL);
1381
- });
1382
- }
1383
-
1384
- // Word document reader
1385
- function readWordDocument(file) {
1386
- if (typeof mammoth === 'undefined') {
1387
- showToast('Mammoth.js not loaded. Please refresh the page and try again.', 'error');
1388
- documentContent.innerHTML = '<p class="text-gray-500 italic">Mammoth.js library not available for Word documents.</p>';
1389
- return;
1390
- }
1391
-
1392
- const reader = new FileReader();
1393
- reader.onload = async (e) => {
1394
- try {
1395
- const options = {
1396
- convertImage: mammoth.images.ignoreImages
1397
- };
1398
-
1399
- const result = await mammoth.extractRawText({
1400
- arrayBuffer: e.target.result
1401
- }, options);
1402
-
1403
- if (result.value && result.value.trim()) {
1404
- setDocumentContent(result.value);
1405
- showToast(`Loaded Word document: ${file.name}`, 'success');
1406
- } else {
1407
- showToast('No text content found in Word document', 'error');
1408
- documentContent.innerHTML = '<p class="text-gray-500 italic">No readable text found in document.</p>';
1409
- }
1410
-
1411
- if (result.messages && result.messages.length > 0) {
1412
- console.warn('Word processing warnings:', result.messages);
1413
- }
1414
- } catch (error) {
1415
- console.error('Error reading Word document:', error);
1416
-
1417
- let errorMessage = 'Error reading Word document.';
1418
- if (error.message.includes('Unsupported file format')) {
1419
- errorMessage = 'Unsupported Word format. Please use .docx format.';
1420
- } else if (error.message.includes('corrupted')) {
1421
- errorMessage = 'Document appears to be corrupted. Please try saving it again.';
1422
- }
1423
-
1424
- showToast(errorMessage, 'error');
1425
- documentContent.innerHTML = '<p class="text-gray-500 italic">Error loading Word document. Please try a different format.</p>';
1426
- }
1427
- };
1428
-
1429
- reader.onerror = () => {
1430
- showToast('Error reading file', 'error');
1431
- };
1432
-
1433
- reader.readAsArrayBuffer(file);
1434
- }
1435
-
1436
- // RTF file reader
1437
- function readRTFFile(file) {
1438
- const reader = new FileReader();
1439
- reader.onload = (e) => {
1440
- let rtfText = e.target.result;
1441
-
1442
- // Basic RTF to plain text conversion
1443
- rtfText = rtfText.replace(/\\[a-z]+[0-9]*\s?/gi, '');
1444
- rtfText = rtfText.replace(/[{}]/g, '');
1445
- rtfText = rtfText.replace(/\\\\/g, '\\');
1446
- rtfText = rtfText.replace(/\\'/g, "'");
1447
- rtfText = rtfText.replace(/\\\n/g, '\n');
1448
- rtfText = rtfText.replace(/\s+/g, ' ');
1449
- rtfText = rtfText.trim();
1450
-
1451
- setDocumentContent(rtfText);
1452
- };
1453
- reader.onerror = () => {
1454
- showToast('Error reading RTF file', 'error');
1455
- };
1456
- reader.readAsText(file);
1457
- }
1458
-
1459
- // Markdown file reader
1460
- function readMarkdownFile(file) {
1461
- const reader = new FileReader();
1462
- reader.onload = (e) => {
1463
- let text = e.target.result;
1464
-
1465
- // Convert basic Markdown to plain text
1466
- text = text.replace(/^#{1,6}\s+/gm, ''); // Headers
1467
- text = text.replace(/\*\*(.*?)\*\*/g, '$1'); // Bold
1468
- text = text.replace(/\*(.*?)\*/g, '$1'); // Italic
1469
- text = text.replace(/_(.*?)_/g, '$1'); // Italic underscore
1470
- text = text.replace(/`(.*?)`/g, '$1'); // Inline code
1471
- text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Links
1472
- text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, ''); // Images
1473
- text = text.replace(/^>\s+/gm, ''); // Blockquotes
1474
- text = text.replace(/^[\s]*-\s+/gm, '• '); // Unordered lists
1475
- text = text.replace(/^[\s]*\d+\.\s+/gm, ''); // Ordered lists
1476
- text = text.replace(/---+/g, ''); // Horizontal rules
1477
- text = text.replace(/\n{3,}/g, '\n\n'); // Multiple newlines
1478
-
1479
- setDocumentContent(text);
1480
- };
1481
- reader.onerror = () => {
1482
- showToast('Error reading Markdown file', 'error');
1483
- };
1484
- reader.readAsText(file);
1485
- }
1486
-
1487
- // JSON file reader
1488
- function readJSONFile(file) {
1489
- const reader = new FileReader();
1490
- reader.onload = (e) => {
1491
- try {
1492
- const jsonData = JSON.parse(e.target.result);
1493
-
1494
- let text = '';
1495
-
1496
- function jsonToText(obj, prefix = '') {
1497
- for (const [key, value] of Object.entries(obj)) {
1498
- if (typeof value === 'object' && value !== null) {
1499
- text += `${prefix}${key}:\n`;
1500
- jsonToText(value, prefix + ' ');
1501
- } else {
1502
- text += `${prefix}${key}: ${value}\n`;
1503
- }
1504
- }
1505
- }
1506
-
1507
- jsonToText(jsonData);
1508
- setDocumentContent(text);
1509
- } catch (error) {
1510
- showToast('Invalid JSON file', 'error');
1511
- }
1512
- };
1513
- reader.onerror = () => {
1514
- showToast('Error reading JSON file', 'error');
1515
- };
1516
- reader.readAsText(file);
1517
- }
1518
-
1519
- // HTML file reader
1520
- function readHTMLFile(file) {
1521
- const reader = new FileReader();
1522
- reader.onload = (e) => {
1523
- const htmlContent = e.target.result;
1524
-
1525
- const tempDiv = document.createElement('div');
1526
- tempDiv.innerHTML = htmlContent;
1527
-
1528
- const scripts = tempDiv.querySelectorAll('script, style');
1529
- scripts.forEach(script => script.remove());
1530
-
1531
- let text = tempDiv.textContent || tempDiv.innerText || '';
1532
-
1533
- text = text.replace(/\s+/g, ' ');
1534
- text = text.replace(/\n\s*\n/g, '\n\n');
1535
- text = text.trim();
1536
-
1537
- setDocumentContent(text);
1538
- };
1539
- reader.onerror = () => {
1540
- showToast('Error reading HTML file', 'error');
1541
- };
1542
- reader.readAsText(file);
1543
- }
1544
-
1545
- // XML file reader
1546
- function readXMLFile(file) {
1547
- const reader = new FileReader();
1548
- reader.onload = (e) => {
1549
- try {
1550
- const xmlContent = e.target.result;
1551
- const parser = new DOMParser();
1552
- const xmlDoc = parser.parseFromString(xmlContent, 'text/xml');
1553
-
1554
- let text = '';
1555
-
1556
- function extractText(node) {
1557
- if (node.nodeType === Node.TEXT_NODE) {
1558
- text += node.textContent + ' ';
1559
- } else if (node.nodeType === Node.ELEMENT_NODE) {
1560
- for (const child of node.childNodes) {
1561
- extractText(child);
1562
- }
1563
- }
1564
- }
1565
-
1566
- extractText(xmlDoc.documentElement);
1567
-
1568
- text = text.replace(/\s+/g, ' ');
1569
- text = text.trim();
1570
-
1571
- setDocumentContent(text);
1572
- } catch (error) {
1573
- showToast('Error parsing XML file', 'error');
1574
- }
1575
- };
1576
- reader.onerror = () => {
1577
- showToast('Error reading XML file', 'error');
1578
- };
1579
- reader.readAsText(file);
1580
- }
1581
-
1582
- // CSV file reader
1583
- function readCSVFile(file) {
1584
- const reader = new FileReader();
1585
- reader.onload = (e) => {
1586
- const csvContent = e.target.result;
1587
-
1588
- const lines = csvContent.split('\n').filter(line => line.trim());
1589
- const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
1590
-
1591
- let text = `CSV Data Summary:\n\n`;
1592
- text += `Headers: ${headers.join(', ')}\n\n`;
1593
- text += `Total rows: ${lines.length - 1}\n\n`;
1594
-
1595
- if (lines.length > 1) {
1596
- text += 'First few rows:\n';
1597
- for (let i = 1; i <= Math.min(5, lines.length - 1); i++) {
1598
- const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
1599
- text += `Row ${i}: ${values.join(', ')}\n`;
1600
- }
1601
- }
1602
-
1603
- setDocumentContent(text);
1604
- showToast('CSV file loaded. Showing data summary for TTS.', 'success');
1605
- };
1606
- reader.onerror = () => {
1607
- showToast('Error reading CSV file', 'error');
1608
- };
1609
- reader.readAsText(file);
1610
- }
1611
-
1612
- // Set document content
1613
- function setDocumentContent(text) {
1614
- currentText = text;
1615
- documentContent.innerHTML = text.split('\n').map(line => `<p>${line || '&nbsp;'}</p>`).join('');
1616
- charCount.textContent = `${text.length} characters`;
1617
-
1618
- if (text.length > 1000000) {
1619
- showToast('Document exceeds 1,000,000 character limit. Some content may be truncated.', 'error');
1620
- }
1621
-
1622
- updateCostEstimator();
1623
- }
1624
-
1625
- // Playback controls
1626
- playBtn.addEventListener('click', async () => {
1627
- if (!selectedVoice) {
1628
- showToast('No voice available. Please check your API key and internet connection.', 'error');
1629
- return;
1630
- }
1631
-
1632
- if (!currentText.trim()) {
1633
- showToast('Please add some text to read', 'error');
1634
- return;
1635
- }
1636
-
1637
- if (isPaused) {
1638
- resumePlayback();
1639
- } else {
1640
- await startPlayback();
1641
- }
1642
- });
1643
-
1644
- pauseBtn.addEventListener('click', () => {
1645
- if (isPlaying && !isPaused) {
1646
- pausePlayback();
1647
- }
1648
- });
1649
-
1650
- stopBtn.addEventListener('click', () => {
1651
- stopPlayback();
1652
- });
1653
-
1654
- // Playback functions
1655
- async function startPlayback() {
1656
  try {
1657
- isPlaying = true;
1658
- isPaused = false;
1659
- updatePlaybackButtons();
1660
-
1661
- showToast('Generating speech...', 'success', 1000);
1662
-
1663
- const chunks = splitTextIntoChunks(currentText, MAX_CHUNK_SIZE);
 
 
 
1664
 
1665
- if (chunks.length > 1) {
1666
- showToast(`Processing ${chunks.length} chunks...`, 'success', 2000);
1667
  }
1668
 
1669
- let allAudioData = [];
1670
 
1671
- for (let i = 0; i < chunks.length; i++) {
1672
- if (!isPlaying || isPaused) break;
1673
-
1674
- const audioData = await synthesizeSpeech(
1675
- chunks[i],
1676
- selectedVoice,
1677
- parseFloat(rateSelect.value),
1678
- parseFloat(pitchSelect.value),
1679
- modelSelect.value
1680
- );
1681
-
1682
- allAudioData.push(audioData);
1683
  }
1684
 
1685
- if (allAudioData.length > 0 && isPlaying) {
1686
- await combineAndPlayAudio(allAudioData);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1687
  }
1688
 
1689
- } catch (error) {
1690
- console.error('Error during playback:', error);
1691
- showToast('Failed to play text: ' + error.message, 'error');
1692
- stopPlayback();
1693
- }
1694
- }
1695
-
1696
- async function combineAndPlayAudio(audioDataArray) {
1697
- try {
1698
- const firstAudioData = audioDataArray[0];
1699
-
1700
- const byteCharacters = atob(firstAudioData);
1701
- const byteNumbers = new Array(byteCharacters.length);
1702
- for (let i = 0; i < byteCharacters.length; i++) {
1703
- byteNumbers[i] = byteCharacters.charCodeAt(i);
1704
- }
1705
- const byteArray = new Uint8Array(byteNumbers);
1706
- currentAudioBlob = new Blob([byteArray], { type: 'audio/mpeg' });
1707
 
1708
- downloadBtn.disabled = false;
1709
- saveToLibraryBtn.disabled = false;
 
 
 
 
 
 
1710
 
1711
- await playAudioData(firstAudioData);
 
 
 
1712
 
1713
  } catch (error) {
1714
- console.error('Error combining audio:', error);
1715
- showToast('Failed to process audio', 'error');
1716
- }
1717
- }
1718
-
1719
- function pausePlayback() {
1720
- if (audioSource && isPlaying && !isPaused) {
1721
- pauseTime = audioContext.currentTime;
1722
- audioSource.stop();
1723
- isPaused = true;
1724
- updatePlaybackButtons();
1725
- }
1726
- }
1727
-
1728
- function resumePlayback() {
1729
- if (isPaused && audioBuffer) {
1730
- const pausedDuration = pauseTime - startTime;
1731
-
1732
- audioSource = audioContext.createBufferSource();
1733
- audioSource.buffer = audioBuffer;
1734
- audioSource.connect(audioContext.destination);
1735
-
1736
- audioSource.start(0, pausedDuration);
1737
- startTime = audioContext.currentTime - pausedDuration;
1738
- isPaused = false;
1739
- isPlaying = true;
1740
- updatePlaybackButtons();
1741
 
1742
- startProgressTracking();
 
 
 
 
 
 
 
 
 
1743
 
1744
- audioSource.onended = () => {
1745
- if (isPlaying && !isPaused) {
1746
- stopPlayback();
1747
- }
1748
- };
1749
- }
1750
- }
1751
-
1752
- function stopPlayback() {
1753
- if (audioSource && isPlaying) {
1754
- audioSource.stop();
1755
- }
1756
-
1757
- isPlaying = false;
1758
- isPaused = false;
1759
- startTime = 0;
1760
- pauseTime = 0;
1761
- currentReadingPosition = 0;
1762
- updatePlaybackButtons();
1763
- updateProgress(0);
1764
-
1765
- documentContent.querySelectorAll('.reading-highlight').forEach(el => {
1766
- el.classList.remove('reading-highlight');
1767
- });
1768
- }
1769
-
1770
- function updatePlaybackButtons() {
1771
- playBtn.disabled = false;
1772
- pauseBtn.disabled = !isPlaying || isPaused;
1773
- stopBtn.disabled = !isPlaying;
1774
- downloadBtn.disabled = !currentAudioBlob;
1775
- saveToLibraryBtn.disabled = !currentAudioBlob;
1776
-
1777
- if (isPlaying && !isPaused) {
1778
- playBtn.innerHTML = '<i class="fas fa-pause"></i>';
1779
- } else {
1780
- playBtn.innerHTML = '<i class="fas fa-play"></i>';
1781
  }
1782
  }
1783
 
1784
- function updateProgress(progress) {
1785
- progressBar.style.width = (progress * 100) + '%';
 
 
1786
 
1787
- const currentSeconds = Math.floor(progress * estimatedDuration);
1788
- const totalSeconds = Math.floor(estimatedDuration);
1789
-
1790
- currentTime.textContent = formatTime(currentSeconds);
1791
- totalTime.textContent = formatTime(totalSeconds);
1792
- }
1793
-
1794
- function splitTextIntoChunks(text, maxChunkSize) {
1795
- const sentences = text.split(/(?<=[.!?])\s+/);
1796
- const chunks = [];
1797
- let currentChunk = '';
1798
 
1799
- for (const sentence of sentences) {
1800
- if (currentChunk.length + sentence.length <= maxChunkSize) {
1801
- currentChunk += (currentChunk ? ' ' : '') + sentence;
1802
- } else {
1803
- if (currentChunk) {
1804
- chunks.push(currentChunk.trim());
1805
- }
1806
- currentChunk = sentence;
1807
- }
1808
- }
1809
 
1810
- if (currentChunk) {
1811
- chunks.push(currentChunk.trim());
1812
- }
1813
 
1814
- return chunks;
1815
- }
1816
-
1817
- // Event listeners
1818
- refreshVoicesBtn.addEventListener('click', loadVoices);
1819
- languageSelect.addEventListener('change', loadVoices);
1820
-
1821
- // Initialize the application
1822
- init();
1823
- });
1824
- </script>
1825
- </body>
1826
- </html>rounded">
1827
- <i class="fas fa-play mr-1"></i> Sample
1828
- </button>
1829
- </div>
1830
- </div>
1831
- <div class="ml-2 text-blue-500 hidden selected-icon">
1832
- <i class="fas fa-check-circle"></i>
1833
- </div>
1834
- </div>
1835
- ${qualityBadge}
1836
- `;
1837
-
1838
- voiceCard.addEventListener('click', () => {
1839
- document.querySelectorAll('.voice-card').forEach(card => {
1840
- card.classList.remove('selected');
1841
- card.querySelector('.selected-icon').classList.add('hidden');
1842
- });
1843
-
1844
- voiceCard.classList.add('selected');
1845
- voiceCard.querySelector('.selected-icon').classList.remove('hidden');
1846
- selectedVoice = voice;
1847
-
1848
- updateCostEstimator();
1849
- });
1850
-
1851
- const playSampleBtn = voiceCard.querySelector('.play-sample');
1852
- playSampleBtn.addEventListener('click', (e) => {
1853
- e.stopPropagation();
1854
- playSample(voice, "Hello, this is a sample of my voice. I can read your documents with natural sounding speech.");
1855
- });
1856
-
1857
- voiceGrid.appendChild(voiceCard);
1858
  });
1859
 
1860
- if (naturalVoices.length > 0) {
1861
- const firstCard = voiceGrid.querySelector('.voice-card');
1862
- if (firstCard) {
1863
- firstCard.click();
 
1864
  updateCostEstimator();
 
1865
  }
1866
- }
1867
- }
1868
-
1869
- // Play a voice sample
1870
- async function playSample(voice, text) {
1871
- if (!apiKey) {
1872
- showToast('Please enter your API key first', 'error');
1873
- return;
1874
- }
1875
-
1876
- if (isPlaying) {
1877
- stopPlayback();
1878
- }
1879
 
1880
- try {
1881
- const audioData = await synthesizeSpeech(text, voice, parseFloat(rateSelect.value), parseFloat(pitchSelect.value), modelSelect.value);
1882
- playAudioData(audioData);
1883
- } catch (error) {
1884
- console.error('Error playing sample:', error);
1885
- showToast('Failed to play sample: ' + error.message, 'error');
1886
  }
1887
  }
1888
 
@@ -1921,27 +984,56 @@
1921
  };
1922
 
1923
  try {
1924
- const response = await fetch(`https://texttospeech.googleapis.com/v1/text:synthesize`, {
1925
- method: 'POST',
1926
- headers: {
1927
- 'X-Goog-Api-Key': cleanApiKey,
1928
- 'Content-Type': 'application/json',
1929
- 'Accept': 'application/json',
1930
- },
1931
- mode: 'cors',
1932
- cache: 'no-cache',
1933
- body: JSON.stringify(request)
1934
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1935
 
1936
  if (!response.ok) {
1937
  let errorMessage = 'Failed to synthesize speech';
 
 
1938
  try {
1939
  const errorData = await response.json();
1940
  errorMessage = errorData.error?.message || errorMessage;
 
1941
  } catch (e) {
1942
- errorMessage = `HTTP ${response.status}: ${response.statusText}`;
 
 
 
 
 
 
 
 
 
 
 
 
1943
  }
1944
- throw new Error(errorMessage);
1945
  }
1946
 
1947
  const data = await response.json();
@@ -2490,7 +1582,7 @@
2490
  // Playback controls
2491
  playBtn.addEventListener('click', async () => {
2492
  if (!selectedVoice) {
2493
- showToast('Please select a voice first', 'error');
2494
  return;
2495
  }
2496
 
@@ -2582,7 +1674,6 @@
2582
 
2583
  function pausePlayback() {
2584
  if (audioSource && isPlaying && !isPaused) {
2585
- audioCon
2586
  pauseTime = audioContext.currentTime;
2587
  audioSource.stop();
2588
  isPaused = true;
@@ -2633,7 +1724,7 @@
2633
  }
2634
 
2635
  function updatePlaybackButtons() {
2636
- playBtn.disabled = isPlaying && !isPaused;
2637
  pauseBtn.disabled = !isPlaying || isPaused;
2638
  stopBtn.disabled = !isPlaying;
2639
  downloadBtn.disabled = !currentAudioBlob;
 
100
  from { transform: translateX(100%); opacity: 0; }
101
  to { transform: translateX(0); opacity: 1; }
102
  }
 
 
 
 
103
 
104
  /* Progress indicator styles */
105
  #progressContainer {
 
170
  <ul class="mt-1 list-disc list-inside space-y-1">
171
  <li>Make sure billing is enabled for your project</li>
172
  <li>Copy ONLY the API key (no extra text)</li>
173
+ <li>If restricted, allow this domain</li>
174
  </ul>
175
  </div>
176
  </div>
 
257
  </button>
258
  </div>
259
  </div>
260
+
261
+ <!-- Voice Selection Dropdown -->
262
+ <div id="voiceSelectionContainer" class="mb-4">
263
+ <!-- Voice dropdown will be inserted here -->
264
+ </div>
265
 
266
  <div id="voiceGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
267
  <!-- Voices will be loaded dynamically -->
 
300
  <div>
301
  <label for="modelSelect" class="block text-sm font-medium text-gray-700 mb-1">Voice Model</label>
302
  <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">
303
+ <option value="studio">Studio Quality - $160.00/1M chars</option>
304
+ <option value="wavenet" selected>WaveNet - $16.00/1M chars</option>
305
+ <option value="neural2">Neural2 - $16.00/1M chars</option>
306
+ <option value="standard">Standard - $4.00/1M chars</option>
307
  </select>
308
  </div>
309
  </div>
 
348
 
349
  <script>
350
  document.addEventListener('DOMContentLoaded', () => {
351
+ // Global state variables
352
  let apiKey = localStorage.getItem('googleTTSApiKey') || '';
353
  let currentText = '';
354
  let selectedVoice = null;
355
+ let availableVoices = [];
356
  let isPlaying = false;
357
  let isPaused = false;
358
  let audioContext = null;
 
375
  // Global error handler
376
  window.addEventListener('error', (e) => {
377
  console.error('Global error caught:', e.error);
 
378
  });
379
 
380
  // Global unhandled promise rejection handler
 
393
  const documentContent = document.getElementById('documentContent');
394
  const charCount = document.getElementById('charCount');
395
  const voiceGrid = document.getElementById('voiceGrid');
396
+ const voiceSelectionContainer = document.getElementById('voiceSelectionContainer');
397
  const refreshVoicesBtn = document.getElementById('refreshVoicesBtn');
398
  const languageSelect = document.getElementById('languageSelect');
399
  const rateSelect = document.getElementById('rateSelect');
 
413
  // Dark mode elements
414
  const darkModeToggle = document.getElementById('darkModeToggle');
415
 
 
 
 
 
 
 
 
 
 
416
  // Initialize function
417
  function init() {
418
  // Load saved API key
 
454
 
455
  // Event listeners to update cost estimates
456
  modelSelect.addEventListener('change', (e) => {
 
457
  updateCostEstimator();
 
 
458
  autoSelectVoiceForModel(e.target.value);
 
 
 
 
 
459
  });
460
 
 
461
  rateSelect.addEventListener('change', updateCostEstimator);
462
  pitchSelect.addEventListener('change', updateCostEstimator);
463
 
464
+ document.getElementById('showCostBreakdown')?.addEventListener('click', showCostBreakdownModal);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  }
466
 
467
  // Voice type and pricing functions
 
528
  }
529
  }
530
 
531
+ // Auto select voice for model
532
+ function autoSelectVoiceForModel(model) {
533
+ if (!availableVoices.length) return;
534
+
535
+ let preferredVoices = [];
536
+
537
+ switch (model) {
538
+ case 'studio':
539
+ preferredVoices = availableVoices.filter(v => v.name.includes('Studio'));
540
+ break;
541
+ case 'wavenet':
542
+ preferredVoices = availableVoices.filter(v => v.name.includes('Wavenet'));
543
+ break;
544
+ case 'neural2':
545
+ preferredVoices = availableVoices.filter(v => v.name.includes('Neural2'));
546
+ break;
547
+ default:
548
+ preferredVoices = availableVoices.filter(v => !v.name.includes('Studio') && !v.name.includes('Wavenet') && !v.name.includes('Neural2'));
549
+ }
550
+
551
+ if (preferredVoices.length > 0) {
552
+ selectedVoice = preferredVoices[0];
553
+ const voiceSelect = document.getElementById('voiceSelect');
554
+ if (voiceSelect) {
555
+ voiceSelect.value = selectedVoice.name;
556
+ }
557
+ updateCostEstimator();
558
+ showToast(`Voice changed to ${selectedVoice.name} for ${model} model`, 'success', 2000);
559
+ }
560
+ }
561
+
562
  // Cost breakdown modal
563
  function showCostBreakdownModal() {
564
  const modal = document.createElement('div');
 
642
  }
643
  }
644
 
645
+ // Dark Mode Implementation
646
+ function initDarkMode() {
647
+ const savedTheme = localStorage.getItem('theme');
648
+ const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
649
+
650
+ if (savedTheme) {
651
+ document.documentElement.setAttribute('data-theme', savedTheme);
652
+ updateDarkModeIcon(savedTheme === 'dark');
653
+ } else if (systemPrefersDark) {
654
+ document.documentElement.setAttribute('data-theme', 'dark');
655
+ updateDarkModeIcon(true);
656
+ } else {
657
+ document.documentElement.setAttribute('data-theme', 'light');
658
+ updateDarkModeIcon(false);
659
+ }
660
+
661
+ darkModeToggle.addEventListener('click', toggleDarkMode);
662
+ }
663
+
664
+ function toggleDarkMode() {
665
+ const currentTheme = document.documentElement.getAttribute('data-theme');
666
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
667
+
668
+ document.documentElement.setAttribute('data-theme', newTheme);
669
+ localStorage.setItem('theme', newTheme);
670
+ updateDarkModeIcon(newTheme === 'dark');
671
+ }
672
+
673
+ function updateDarkModeIcon(isDark) {
674
+ const icon = darkModeToggle.querySelector('i');
675
+ icon.className = isDark ? 'fas fa-sun text-xl' : 'fas fa-moon text-xl';
676
+ }
677
+
678
+ // Progress indicator initialization
679
+ function initProgressIndicator() {
680
+ progressContainer.addEventListener('click', (e) => {
681
+ if (!audioSource || !audioBuffer) return;
682
+
683
+ const rect = progressContainer.getBoundingClientRect();
684
+ const clickX = e.clientX - rect.left;
685
+ const percentage = clickX / rect.width;
686
+ const newTime = percentage * audioBuffer.duration;
687
+
688
+ seekToTime(newTime);
689
+ });
690
+ }
691
+
692
+ function seekToTime(time) {
693
+ if (!audioSource || !audioBuffer) return;
694
+
695
+ audioSource.stop();
696
+
697
+ audioSource = audioContext.createBufferSource();
698
+ audioSource.buffer = audioBuffer;
699
+ audioSource.connect(audioContext.destination);
700
+
701
+ startTime = audioContext.currentTime - time;
702
+ audioSource.start(0, time);
703
+
704
+ audioSource.onended = () => {
705
+ if (isPlaying && !isPaused) {
706
+ stopPlayback();
707
+ }
708
+ };
709
+ }
710
+
711
+ // Download feature initialization
712
+ function initDownloadFeature() {
713
+ downloadBtn.addEventListener('click', downloadAudio);
714
+ }
715
+
716
+ async function downloadAudio() {
717
+ if (!currentAudioBlob) {
718
+ showToast('No audio available to download', 'error');
719
+ return;
720
+ }
721
+
722
+ try {
723
+ const url = URL.createObjectURL(currentAudioBlob);
724
+ const a = document.createElement('a');
725
+ a.href = url;
726
+ a.download = `tts-audio-${new Date().toISOString().split('T')[0]}.mp3`;
727
+ document.body.appendChild(a);
728
+ a.click();
729
+ document.body.removeChild(a);
730
+ URL.revokeObjectURL(url);
731
+
732
+ showToast('Audio downloaded successfully', 'success');
733
+ } catch (error) {
734
+ console.error('Download error:', error);
735
+ showToast('Failed to download audio', 'error');
736
+ }
737
+ }
738
+
739
  // Toast notifications
740
  function showToast(message, type = 'success', duration = 3000) {
741
  const toast = document.createElement('div');
 
749
  }, duration);
750
  }
751
 
752
+ // Utility function for file size formatting
753
+ function formatFileSize(bytes) {
754
+ if (bytes === 0) return '0 Bytes';
755
+ const k = 1024;
756
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
757
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
758
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
759
+ }
760
+
761
  // API Key Management
762
  saveKeyBtn.addEventListener('click', async () => {
763
  const key = apiKeyInput.value ? apiKeyInput.value.trim() : '';
 
826
  <p class="text-gray-500 mt-2">Loading Google TTS voices...</p>
827
  </div>
828
  `;
829
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
830
  try {
831
+ const languageCode = languageSelect.value;
832
+ const response = await fetch(`https://texttospeech.googleapis.com/v1/voices?languageCode=${languageCode}`, {
833
+ method: 'GET',
834
+ headers: {
835
+ 'X-Goog-Api-Key': apiKey.trim(),
836
+ 'Accept': 'application/json',
837
+ },
838
+ mode: 'cors',
839
+ cache: 'no-cache'
840
+ });
841
 
842
+ if (!response.ok) {
843
+ throw new Error(`API error (${response.status}): Failed to load voices`);
844
  }
845
 
846
+ const data = await response.json();
847
 
848
+ if (!data || !data.voices) {
849
+ throw new Error('Invalid response from Google TTS API - no voices returned');
 
 
 
 
 
 
 
 
 
 
850
  }
851
 
852
+ // Filter for natural HD voices and store them
853
+ const naturalVoices = data.voices.filter(voice =>
854
+ voice.name.includes('Wavenet') ||
855
+ voice.name.includes('Studio') ||
856
+ voice.name.includes('Neural2')
857
+ );
858
+
859
+ naturalVoices.sort((a, b) => a.name.localeCompare(b.name));
860
+ availableVoices = naturalVoices;
861
+
862
+ if (naturalVoices.length === 0) {
863
+ voiceGrid.innerHTML = `
864
+ <div class="col-span-full text-center py-8">
865
+ <i class="fas fa-microphone-slash text-gray-400 text-2xl"></i>
866
+ <p class="text-gray-500 mt-2">No high-quality voices available for selected language</p>
867
+ </div>
868
+ `;
869
+ return;
870
  }
871
 
872
+ // Create voice dropdown selector
873
+ createVoiceDropdown(naturalVoices);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
874
 
875
+ // Show success message
876
+ voiceGrid.innerHTML = `
877
+ <div class="col-span-full text-center py-8">
878
+ <i class="fas fa-check-circle text-green-500 text-2xl"></i>
879
+ <p class="text-gray-500 mt-2">${naturalVoices.length} voices loaded successfully</p>
880
+ <p class="text-gray-400 text-sm mt-1">Voice can be selected from the dropdown above</p>
881
+ </div>
882
+ `;
883
 
884
+ // Automatically select the first available voice based on current model
885
+ const currentModel = modelSelect.value || 'wavenet';
886
+ autoSelectVoiceForModel(currentModel);
887
+ updateCostEstimator();
888
 
889
  } catch (error) {
890
+ console.error('Error loading voices:', error);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
891
 
892
+ voiceGrid.innerHTML = `
893
+ <div class="col-span-full text-center py-8">
894
+ <i class="fas fa-exclamation-triangle text-red-400 text-2xl"></i>
895
+ <p class="text-gray-500 mt-2">Failed to load voices</p>
896
+ <p class="text-red-500 text-sm mt-1 max-w-md mx-auto">${error.message}</p>
897
+ <button id="retryVoices" class="mt-4 bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded text-sm">
898
+ <i class="fas fa-sync-alt mr-1"></i> Retry
899
+ </button>
900
+ </div>
901
+ `;
902
 
903
+ document.getElementById('retryVoices')?.addEventListener('click', loadVoices);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
904
  }
905
  }
906
 
907
+ // Create voice dropdown selector
908
+ function createVoiceDropdown(voices) {
909
+ // Clear existing dropdown if any
910
+ voiceSelectionContainer.innerHTML = '';
911
 
912
+ // Create voice selector dropdown
913
+ const voiceDropdownContainer = document.createElement('div');
914
+ voiceDropdownContainer.className = 'mb-4';
915
+ voiceDropdownContainer.innerHTML = `
916
+ <label for="voiceSelect" class="block text-sm font-medium text-gray-700 mb-1">Voice Selection</label>
917
+ <select id="voiceSelect" class="bg-white border border-gray-300 rounded-md py-2 px-3 w-full focus:outline-none focus:ring-blue-500 focus:border-blue-500">
918
+ <option value="">Select a voice...</option>
919
+ </select>
920
+ `;
 
 
921
 
922
+ voiceSelectionContainer.appendChild(voiceDropdownContainer);
 
 
 
 
 
 
 
 
 
923
 
924
+ const voiceSelect = document.getElementById('voiceSelect');
 
 
925
 
926
+ // Populate dropdown with voices
927
+ voices.forEach(voice => {
928
+ const option = document.createElement('option');
929
+ option.value = voice.name;
930
+ option.textContent = `${voice.name} (${voice.ssmlGender})`;
931
+ voiceSelect.appendChild(option);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
932
  });
933
 
934
+ // Add event listener for voice selection
935
+ voiceSelect.addEventListener('change', (e) => {
936
+ const selectedVoiceName = e.target.value;
937
+ if (selectedVoiceName) {
938
+ selectedVoice = voices.find(voice => voice.name === selectedVoiceName);
939
  updateCostEstimator();
940
+ showToast(`Voice changed to ${selectedVoice.name}`, 'success', 1500);
941
  }
942
+ });
 
 
 
 
 
 
 
 
 
 
 
 
943
 
944
+ // Auto-select first voice
945
+ if (voices.length > 0) {
946
+ voiceSelect.value = voices[0].name;
947
+ selectedVoice = voices[0];
948
+ updateCostEstimator();
 
949
  }
950
  }
951
 
 
984
  };
985
 
986
  try {
987
+ let response;
988
+ try {
989
+ response = await fetch(`https://texttospeech.googleapis.com/v1/text:synthesize`, {
990
+ method: 'POST',
991
+ headers: {
992
+ 'X-Goog-Api-Key': cleanApiKey,
993
+ 'Content-Type': 'application/json',
994
+ 'Accept': 'application/json',
995
+ },
996
+ mode: 'cors',
997
+ cache: 'no-cache',
998
+ body: JSON.stringify(request)
999
+ });
1000
+ } catch (headerError) {
1001
+ const url = `https://texttospeech.googleapis.com/v1/text:synthesize?key=${encodeURIComponent(cleanApiKey)}`;
1002
+ response = await fetch(url, {
1003
+ method: 'POST',
1004
+ headers: {
1005
+ 'Content-Type': 'application/json',
1006
+ 'Accept': 'application/json',
1007
+ },
1008
+ mode: 'cors',
1009
+ cache: 'no-cache',
1010
+ body: JSON.stringify(request)
1011
+ });
1012
+ }
1013
 
1014
  if (!response.ok) {
1015
  let errorMessage = 'Failed to synthesize speech';
1016
+ let details = '';
1017
+
1018
  try {
1019
  const errorData = await response.json();
1020
  errorMessage = errorData.error?.message || errorMessage;
1021
+ details = errorData.error?.details ? ` (${JSON.stringify(errorData.error.details)})` : '';
1022
  } catch (e) {
1023
+ details = ` (HTTP ${response.status}: ${response.statusText})`;
1024
+ }
1025
+
1026
+ if (response.status === 400) {
1027
+ throw new Error('Invalid request parameters. Please check your text and settings.' + details);
1028
+ } else if (response.status === 403) {
1029
+ throw new Error('Access denied. Please check your API key and quota.' + details);
1030
+ } else if (response.status === 429) {
1031
+ throw new Error('Rate limit exceeded. Please wait a moment and try again.' + details);
1032
+ } else if (response.status >= 500) {
1033
+ throw new Error('Google Cloud service error. Please try again later.' + details);
1034
+ } else {
1035
+ throw new Error(errorMessage + details);
1036
  }
 
1037
  }
1038
 
1039
  const data = await response.json();
 
1582
  // Playback controls
1583
  playBtn.addEventListener('click', async () => {
1584
  if (!selectedVoice) {
1585
+ showToast('Please select a voice from the dropdown', 'error');
1586
  return;
1587
  }
1588
 
 
1674
 
1675
  function pausePlayback() {
1676
  if (audioSource && isPlaying && !isPaused) {
 
1677
  pauseTime = audioContext.currentTime;
1678
  audioSource.stop();
1679
  isPaused = true;
 
1724
  }
1725
 
1726
  function updatePlaybackButtons() {
1727
+ playBtn.disabled = false;
1728
  pauseBtn.disabled = !isPlaying || isPaused;
1729
  stopBtn.disabled = !isPlaying;
1730
  downloadBtn.disabled = !currentAudioBlob;