Spaces:
Running
Running
Update index.html
Browse files- index.html +122 -220
index.html
CHANGED
|
@@ -1,5 +1,14 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
<html lang="en">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
@@ -59,19 +68,6 @@
|
|
| 59 |
border-radius: 3px;
|
| 60 |
padding: 0 2px;
|
| 61 |
}
|
| 62 |
-
.voice-card {
|
| 63 |
-
transition: all 0.2s ease;
|
| 64 |
-
border: 1px solid var(--border-color);
|
| 65 |
-
background-color: var(--card-bg);
|
| 66 |
-
}
|
| 67 |
-
.voice-card:hover {
|
| 68 |
-
border-color: #3B82F6;
|
| 69 |
-
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
| 70 |
-
}
|
| 71 |
-
.voice-card.selected {
|
| 72 |
-
border-color: #3B82F6;
|
| 73 |
-
background-color: #EFF6FF;
|
| 74 |
-
}
|
| 75 |
.loading-spinner {
|
| 76 |
border: 3px solid rgba(255, 255, 255, 0.3);
|
| 77 |
border-radius: 50%;
|
|
@@ -118,33 +114,6 @@
|
|
| 118 |
transition: all 0.2s;
|
| 119 |
}
|
| 120 |
|
| 121 |
-
/* Enhanced voice selector styles */
|
| 122 |
-
.voice-selector {
|
| 123 |
-
background: linear-gradient(145deg, #f9fafb, #ffffff);
|
| 124 |
-
border-radius: 12px;
|
| 125 |
-
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
.voice-option {
|
| 129 |
-
transition: all 0.2s ease;
|
| 130 |
-
border-radius: 8px;
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
.voice-option:hover {
|
| 134 |
-
background-color: #f3f4f6;
|
| 135 |
-
transform: translateY(-1px);
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
.voice-option.selected {
|
| 139 |
-
background-color: #3b82f6;
|
| 140 |
-
color: white;
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
.voice-category {
|
| 144 |
-
border-left: 4px solid #3b82f6;
|
| 145 |
-
background-color: #f8fafc;
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
/* Audio controls enhancement */
|
| 149 |
.audio-controls {
|
| 150 |
background: linear-gradient(145deg, #ffffff, #f9fafb);
|
|
@@ -178,6 +147,11 @@
|
|
| 178 |
background: linear-gradient(45deg, #3b82f6, #6366f1);
|
| 179 |
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
|
| 180 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
</style>
|
| 182 |
</head>
|
| 183 |
<body class="bg-gray-50 min-h-screen">
|
|
@@ -274,7 +248,7 @@
|
|
| 274 |
</div>
|
| 275 |
</div>
|
| 276 |
|
| 277 |
-
<!--
|
| 278 |
<div class="border-t border-gray-200 p-6 bg-gray-50">
|
| 279 |
<div class="flex justify-between items-center mb-4">
|
| 280 |
<h2 class="text-xl font-semibold text-gray-800">Voice & Settings</h2>
|
|
@@ -319,41 +293,19 @@
|
|
| 319 |
</div>
|
| 320 |
</div>
|
| 321 |
|
| 322 |
-
<!--
|
| 323 |
-
<div class="voice-
|
| 324 |
-
<div class="
|
| 325 |
-
<
|
| 326 |
-
|
| 327 |
-
<
|
| 328 |
-
<
|
| 329 |
-
|
| 330 |
-
</h3>
|
| 331 |
-
<div id="maleVoices" class="space-y-2">
|
| 332 |
-
<!-- Male voices will be populated here -->
|
| 333 |
-
</div>
|
| 334 |
-
</div>
|
| 335 |
-
|
| 336 |
-
<!-- Female Voices -->
|
| 337 |
-
<div class="voice-category p-3 rounded-lg">
|
| 338 |
-
<h3 class="font-semibold text-gray-700 mb-2 flex items-center">
|
| 339 |
-
<i class="fas fa-venus text-pink-500 mr-2"></i>
|
| 340 |
-
Female Voices
|
| 341 |
-
</h3>
|
| 342 |
-
<div id="femaleVoices" class="space-y-2">
|
| 343 |
-
<!-- Female voices will be populated here -->
|
| 344 |
-
</div>
|
| 345 |
-
</div>
|
| 346 |
-
|
| 347 |
-
<!-- Neutral Voices -->
|
| 348 |
-
<div class="voice-category p-3 rounded-lg">
|
| 349 |
-
<h3 class="font-semibold text-gray-700 mb-2 flex items-center">
|
| 350 |
-
<i class="fas fa-user text-purple-500 mr-2"></i>
|
| 351 |
-
Neutral Voices
|
| 352 |
-
</h3>
|
| 353 |
-
<div id="neutralVoices" class="space-y-2">
|
| 354 |
-
<!-- Neutral voices will be populated here -->
|
| 355 |
</div>
|
| 356 |
</div>
|
|
|
|
|
|
|
|
|
|
| 357 |
</div>
|
| 358 |
</div>
|
| 359 |
|
|
@@ -385,10 +337,10 @@
|
|
| 385 |
<div>
|
| 386 |
<label for="modelSelect" class="block text-sm font-medium text-gray-700 mb-1">Voice Model</label>
|
| 387 |
<select id="modelSelect" 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">
|
| 388 |
-
<option value="
|
| 389 |
-
<option value="
|
| 390 |
-
<option value="
|
| 391 |
-
<option value="
|
| 392 |
</select>
|
| 393 |
</div>
|
| 394 |
</div>
|
|
@@ -398,8 +350,8 @@
|
|
| 398 |
<div class="audio-controls p-6 m-4">
|
| 399 |
<div class="flex flex-wrap justify-between items-center gap-4 mb-4">
|
| 400 |
<div class="flex items-center gap-2">
|
| 401 |
-
<span class="text-sm font-medium text-gray-700">
|
| 402 |
-
<span id="
|
| 403 |
</div>
|
| 404 |
|
| 405 |
<div class="playback-controls flex items-center gap-3">
|
|
@@ -471,16 +423,18 @@
|
|
| 471 |
studio: 160.00
|
| 472 |
};
|
| 473 |
|
| 474 |
-
//
|
| 475 |
-
const
|
| 476 |
-
'en-US-Wavenet-A',
|
| 477 |
-
'en-US-Wavenet-B',
|
| 478 |
-
'en-US-Wavenet-D',
|
| 479 |
-
'en-US-Neural2-A',
|
| 480 |
-
'en-US-Neural2-D',
|
| 481 |
'en-US-Standard-A',
|
| 482 |
-
'en-US-Standard-B',
|
| 483 |
-
'en-US-Standard-D'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
];
|
| 485 |
|
| 486 |
// Global error handler
|
|
@@ -503,9 +457,6 @@
|
|
| 503 |
const fileInput = document.getElementById('fileInput');
|
| 504 |
const documentContent = document.getElementById('documentContent');
|
| 505 |
const charCount = document.getElementById('charCount');
|
| 506 |
-
const maleVoices = document.getElementById('maleVoices');
|
| 507 |
-
const femaleVoices = document.getElementById('femaleVoices');
|
| 508 |
-
const neutralVoices = document.getElementById('neutralVoices');
|
| 509 |
const refreshVoicesBtn = document.getElementById('refreshVoicesBtn');
|
| 510 |
const languageSelect = document.getElementById('languageSelect');
|
| 511 |
const rateSelect = document.getElementById('rateSelect');
|
|
@@ -515,7 +466,10 @@
|
|
| 515 |
const pauseBtn = document.getElementById('pauseBtn');
|
| 516 |
const stopBtn = document.getElementById('stopBtn');
|
| 517 |
const downloadBtn = document.getElementById('downloadBtn');
|
| 518 |
-
const
|
|
|
|
|
|
|
|
|
|
| 519 |
const currentTime = document.getElementById('currentTime');
|
| 520 |
const totalTime = document.getElementById('totalTime');
|
| 521 |
const progressContainer = document.getElementById('progressContainer');
|
|
@@ -577,6 +531,13 @@
|
|
| 577 |
pitchSelect.addEventListener('change', updateCostEstimator);
|
| 578 |
|
| 579 |
document.getElementById('showCostBreakdown')?.addEventListener('click', showCostBreakdownModal);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
}
|
| 581 |
|
| 582 |
// Voice type and pricing functions
|
|
@@ -643,29 +604,33 @@
|
|
| 643 |
}
|
| 644 |
}
|
| 645 |
|
| 646 |
-
// Find and select default male voice
|
| 647 |
-
function
|
| 648 |
if (!availableVoices.length) return;
|
| 649 |
|
| 650 |
-
|
| 651 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
if (voice) {
|
| 653 |
selectVoice(voice);
|
| 654 |
return voice;
|
| 655 |
}
|
| 656 |
}
|
| 657 |
|
| 658 |
-
// Fallback to first male voice
|
| 659 |
-
const
|
| 660 |
-
if (
|
| 661 |
-
selectVoice(
|
| 662 |
-
return
|
| 663 |
}
|
| 664 |
|
| 665 |
-
// Final fallback to any voice
|
| 666 |
-
if (
|
| 667 |
-
selectVoice(
|
| 668 |
-
return
|
| 669 |
}
|
| 670 |
|
| 671 |
return null;
|
|
@@ -687,8 +652,9 @@
|
|
| 687 |
case 'neural2':
|
| 688 |
preferredVoices = availableVoices.filter(v => v.name.includes('Neural2'));
|
| 689 |
break;
|
| 690 |
-
|
| 691 |
-
preferredVoices = availableVoices.filter(v =>
|
|
|
|
| 692 |
}
|
| 693 |
|
| 694 |
if (preferredVoices.length > 0) {
|
|
@@ -716,7 +682,7 @@
|
|
| 716 |
<div class="space-y-3">
|
| 717 |
<div class="bg-green-50 p-3 rounded border border-green-200">
|
| 718 |
<div class="flex justify-between items-center">
|
| 719 |
-
<span class="font-medium text-green-800">Standard Voices</span>
|
| 720 |
<span class="text-green-900 font-bold">$4.00</span>
|
| 721 |
</div>
|
| 722 |
<div class="text-sm text-green-600">per million characters</div>
|
|
@@ -907,6 +873,23 @@
|
|
| 907 |
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
| 908 |
}
|
| 909 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
// API Key Management
|
| 911 |
saveKeyBtn.addEventListener('click', async () => {
|
| 912 |
const key = apiKeyInput.value ? apiKeyInput.value.trim() : '';
|
|
@@ -959,11 +942,15 @@
|
|
| 959 |
// Load available voices from Google TTS
|
| 960 |
async function loadVoices() {
|
| 961 |
if (!apiKey) {
|
| 962 |
-
|
|
|
|
|
|
|
| 963 |
return;
|
| 964 |
}
|
| 965 |
|
| 966 |
-
|
|
|
|
|
|
|
| 967 |
|
| 968 |
try {
|
| 969 |
const languageCode = languageSelect.value;
|
|
@@ -987,126 +974,40 @@
|
|
| 987 |
throw new Error('Invalid response from Google TTS API - no voices returned');
|
| 988 |
}
|
| 989 |
|
| 990 |
-
//
|
| 991 |
-
|
| 992 |
-
voice.name.includes('Wavenet') ||
|
| 993 |
-
voice.name.includes('Studio') ||
|
| 994 |
-
voice.name.includes('Neural2')
|
| 995 |
-
);
|
| 996 |
-
|
| 997 |
-
naturalVoices.sort((a, b) => a.name.localeCompare(b.name));
|
| 998 |
-
availableVoices = naturalVoices;
|
| 999 |
-
|
| 1000 |
-
if (naturalVoices.length === 0) {
|
| 1001 |
-
showErrorState('No high-quality voices available for selected language');
|
| 1002 |
-
return;
|
| 1003 |
-
}
|
| 1004 |
-
|
| 1005 |
-
// Create categorized voice interface
|
| 1006 |
-
createVoiceInterface(naturalVoices);
|
| 1007 |
|
| 1008 |
-
//
|
| 1009 |
-
|
| 1010 |
updateCostEstimator();
|
|
|
|
| 1011 |
|
| 1012 |
-
showToast(`Loaded ${
|
| 1013 |
|
| 1014 |
} catch (error) {
|
| 1015 |
console.error('Error loading voices:', error);
|
| 1016 |
-
|
|
|
|
|
|
|
| 1017 |
}
|
| 1018 |
}
|
| 1019 |
|
| 1020 |
-
function showLoadingState(message) {
|
| 1021 |
-
[maleVoices, femaleVoices, neutralVoices].forEach(container => {
|
| 1022 |
-
container.innerHTML = `
|
| 1023 |
-
<div class="text-center py-4">
|
| 1024 |
-
<div class="loading-spinner mx-auto mb-2"></div>
|
| 1025 |
-
<p class="text-gray-500 text-sm">${message}</p>
|
| 1026 |
-
</div>
|
| 1027 |
-
`;
|
| 1028 |
-
});
|
| 1029 |
-
}
|
| 1030 |
-
|
| 1031 |
-
function showErrorState(message) {
|
| 1032 |
-
[maleVoices, femaleVoices, neutralVoices].forEach(container => {
|
| 1033 |
-
container.innerHTML = `
|
| 1034 |
-
<div class="text-center py-4">
|
| 1035 |
-
<i class="fas fa-exclamation-triangle text-red-400 text-lg mb-2"></i>
|
| 1036 |
-
<p class="text-red-500 text-sm">${message}</p>
|
| 1037 |
-
</div>
|
| 1038 |
-
`;
|
| 1039 |
-
});
|
| 1040 |
-
}
|
| 1041 |
-
|
| 1042 |
-
// Create categorized voice interface
|
| 1043 |
-
function createVoiceInterface(voices) {
|
| 1044 |
-
const male = voices.filter(v => v.ssmlGender === 'MALE');
|
| 1045 |
-
const female = voices.filter(v => v.ssmlGender === 'FEMALE');
|
| 1046 |
-
const neutral = voices.filter(v => v.ssmlGender === 'NEUTRAL');
|
| 1047 |
-
|
| 1048 |
-
maleVoices.innerHTML = male.length ? male.map(voice => createVoiceOption(voice)).join('') : '<p class="text-gray-500 text-sm">No male voices available</p>';
|
| 1049 |
-
femaleVoices.innerHTML = female.length ? female.map(voice => createVoiceOption(voice)).join('') : '<p class="text-gray-500 text-sm">No female voices available</p>';
|
| 1050 |
-
neutralVoices.innerHTML = neutral.length ? neutral.map(voice => createVoiceOption(voice)).join('') : '<p class="text-gray-500 text-sm">No neutral voices available</p>';
|
| 1051 |
-
|
| 1052 |
-
// Add click listeners to all voice options
|
| 1053 |
-
document.querySelectorAll('.voice-option').forEach(option => {
|
| 1054 |
-
option.addEventListener('click', () => {
|
| 1055 |
-
const voiceName = option.dataset.voiceName;
|
| 1056 |
-
const voice = voices.find(v => v.name === voiceName);
|
| 1057 |
-
if (voice) {
|
| 1058 |
-
selectVoice(voice);
|
| 1059 |
-
}
|
| 1060 |
-
});
|
| 1061 |
-
});
|
| 1062 |
-
}
|
| 1063 |
-
|
| 1064 |
-
function createVoiceOption(voice) {
|
| 1065 |
-
const voiceType = getVoiceTypeAndPricing(voice.name);
|
| 1066 |
-
const displayName = voice.name.replace(/-/g, ' ').replace(/en US /i, '').replace(/Wavenet|Neural2|Studio/i, '');
|
| 1067 |
-
|
| 1068 |
-
return `
|
| 1069 |
-
<div class="voice-option p-3 border border-gray-200 rounded-lg cursor-pointer" data-voice-name="${voice.name}">
|
| 1070 |
-
<div class="flex justify-between items-center">
|
| 1071 |
-
<div>
|
| 1072 |
-
<div class="font-medium text-gray-800">${displayName}</div>
|
| 1073 |
-
<div class="text-xs text-gray-500">${voiceType.name} • ${voice.languageCodes[0]}</div>
|
| 1074 |
-
</div>
|
| 1075 |
-
<div class="text-right">
|
| 1076 |
-
<div class="text-xs text-blue-600">$${voiceType.rate}/1M</div>
|
| 1077 |
-
<button class="sample-btn text-xs text-gray-600 hover:text-blue-600 mt-1">
|
| 1078 |
-
<i class="fas fa-play text-xs"></i> Sample
|
| 1079 |
-
</button>
|
| 1080 |
-
</div>
|
| 1081 |
-
</div>
|
| 1082 |
-
</div>
|
| 1083 |
-
`;
|
| 1084 |
-
}
|
| 1085 |
-
|
| 1086 |
function selectVoice(voice) {
|
| 1087 |
selectedVoice = voice;
|
| 1088 |
-
selectedVoiceName.textContent = voice.name;
|
| 1089 |
|
| 1090 |
-
// Update
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
const
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
}
|
| 1098 |
|
|
|
|
| 1099 |
updateCostEstimator();
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
const sampleBtn = selectedOption?.querySelector('.sample-btn');
|
| 1104 |
-
if (sampleBtn) {
|
| 1105 |
-
sampleBtn.addEventListener('click', (e) => {
|
| 1106 |
-
e.stopPropagation();
|
| 1107 |
-
playSample(voice);
|
| 1108 |
-
});
|
| 1109 |
-
}
|
| 1110 |
}
|
| 1111 |
|
| 1112 |
// Play voice sample
|
|
@@ -1123,7 +1024,7 @@
|
|
| 1123 |
try {
|
| 1124 |
showSynthesisProgress(0);
|
| 1125 |
const sampleText = "Hello, this is a sample of my voice. I can read your documents with natural sounding speech.";
|
| 1126 |
-
const audioData = await synthesizeSpeech(sampleText, voice, 1, 0,
|
| 1127 |
hideSynthesisProgress();
|
| 1128 |
playAudioData(audioData);
|
| 1129 |
} catch (error) {
|
|
@@ -1748,7 +1649,7 @@
|
|
| 1748 |
for (let i = 1; i <= Math.min(5, lines.length - 1); i++) {
|
| 1749 |
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
| 1750 |
text += `Row ${i}: ${values.join(', ')}\n`;
|
| 1751 |
-
|
| 1752 |
}
|
| 1753 |
|
| 1754 |
setDocumentContent(text);
|
|
@@ -1771,12 +1672,13 @@
|
|
| 1771 |
}
|
| 1772 |
|
| 1773 |
updateCostEstimator();
|
|
|
|
| 1774 |
}
|
| 1775 |
|
| 1776 |
// Playback controls
|
| 1777 |
playBtn.addEventListener('click', async () => {
|
| 1778 |
if (!selectedVoice) {
|
| 1779 |
-
showToast('Please
|
| 1780 |
return;
|
| 1781 |
}
|
| 1782 |
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Google TTS Document Reader (BYOK)</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<style>
|
| 10 |
+
:root {<!DOCTYPE html>
|
| 11 |
+
<html lang="en">
|
| 12 |
<head>
|
| 13 |
<meta charset="UTF-8">
|
| 14 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
| 68 |
border-radius: 3px;
|
| 69 |
padding: 0 2px;
|
| 70 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
.loading-spinner {
|
| 72 |
border: 3px solid rgba(255, 255, 255, 0.3);
|
| 73 |
border-radius: 50%;
|
|
|
|
| 114 |
transition: all 0.2s;
|
| 115 |
}
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
/* Audio controls enhancement */
|
| 118 |
.audio-controls {
|
| 119 |
background: linear-gradient(145deg, #ffffff, #f9fafb);
|
|
|
|
| 147 |
background: linear-gradient(45deg, #3b82f6, #6366f1);
|
| 148 |
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
|
| 149 |
}
|
| 150 |
+
|
| 151 |
+
.voice-status {
|
| 152 |
+
background: linear-gradient(145deg, #f0f9ff, #e0f2fe);
|
| 153 |
+
border: 1px solid #0ea5e9;
|
| 154 |
+
}
|
| 155 |
</style>
|
| 156 |
</head>
|
| 157 |
<body class="bg-gray-50 min-h-screen">
|
|
|
|
| 248 |
</div>
|
| 249 |
</div>
|
| 250 |
|
| 251 |
+
<!-- Voice & Settings Section -->
|
| 252 |
<div class="border-t border-gray-200 p-6 bg-gray-50">
|
| 253 |
<div class="flex justify-between items-center mb-4">
|
| 254 |
<h2 class="text-xl font-semibold text-gray-800">Voice & Settings</h2>
|
|
|
|
| 293 |
</div>
|
| 294 |
</div>
|
| 295 |
|
| 296 |
+
<!-- Voice Status Display -->
|
| 297 |
+
<div id="voiceStatus" class="voice-status p-4 rounded-lg mb-6">
|
| 298 |
+
<div class="flex items-center justify-between">
|
| 299 |
+
<div class="flex items-center">
|
| 300 |
+
<i class="fas fa-microphone text-blue-600 mr-3"></i>
|
| 301 |
+
<div>
|
| 302 |
+
<div class="font-medium text-gray-800" id="currentVoiceName">Loading voices...</div>
|
| 303 |
+
<div class="text-sm text-gray-600" id="currentVoiceDetails">Please wait while voices are loaded</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
</div>
|
| 305 |
</div>
|
| 306 |
+
<button id="playVoiceSample" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm transition" disabled>
|
| 307 |
+
<i class="fas fa-play mr-1"></i> Play Sample
|
| 308 |
+
</button>
|
| 309 |
</div>
|
| 310 |
</div>
|
| 311 |
|
|
|
|
| 337 |
<div>
|
| 338 |
<label for="modelSelect" class="block text-sm font-medium text-gray-700 mb-1">Voice Model</label>
|
| 339 |
<select id="modelSelect" 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">
|
| 340 |
+
<option value="standard" selected>Standard - $4.00/1M chars (Default)</option>
|
| 341 |
+
<option value="wavenet">WaveNet - $16.00/1M chars</option>
|
| 342 |
+
<option value="neural2">Neural2 - $16.00/1M chars</option>
|
| 343 |
+
<option value="studio">Studio - $160.00/1M chars</option>
|
| 344 |
</select>
|
| 345 |
</div>
|
| 346 |
</div>
|
|
|
|
| 350 |
<div class="audio-controls p-6 m-4">
|
| 351 |
<div class="flex flex-wrap justify-between items-center gap-4 mb-4">
|
| 352 |
<div class="flex items-center gap-2">
|
| 353 |
+
<span class="text-sm font-medium text-gray-700">Ready to synthesize:</span>
|
| 354 |
+
<span id="synthesisStatus" class="text-sm text-blue-600 font-medium">Select voice and add text</span>
|
| 355 |
</div>
|
| 356 |
|
| 357 |
<div class="playback-controls flex items-center gap-3">
|
|
|
|
| 423 |
studio: 160.00
|
| 424 |
};
|
| 425 |
|
| 426 |
+
// Priority list for Standard male voices
|
| 427 |
+
const defaultStandardMaleVoices = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
'en-US-Standard-A',
|
| 429 |
+
'en-US-Standard-B',
|
| 430 |
+
'en-US-Standard-D',
|
| 431 |
+
'en-US-Standard-I',
|
| 432 |
+
'en-GB-Standard-A',
|
| 433 |
+
'en-GB-Standard-B',
|
| 434 |
+
'en-GB-Standard-D',
|
| 435 |
+
'en-AU-Standard-A',
|
| 436 |
+
'en-AU-Standard-B',
|
| 437 |
+
'en-AU-Standard-D'
|
| 438 |
];
|
| 439 |
|
| 440 |
// Global error handler
|
|
|
|
| 457 |
const fileInput = document.getElementById('fileInput');
|
| 458 |
const documentContent = document.getElementById('documentContent');
|
| 459 |
const charCount = document.getElementById('charCount');
|
|
|
|
|
|
|
|
|
|
| 460 |
const refreshVoicesBtn = document.getElementById('refreshVoicesBtn');
|
| 461 |
const languageSelect = document.getElementById('languageSelect');
|
| 462 |
const rateSelect = document.getElementById('rateSelect');
|
|
|
|
| 466 |
const pauseBtn = document.getElementById('pauseBtn');
|
| 467 |
const stopBtn = document.getElementById('stopBtn');
|
| 468 |
const downloadBtn = document.getElementById('downloadBtn');
|
| 469 |
+
const currentVoiceName = document.getElementById('currentVoiceName');
|
| 470 |
+
const currentVoiceDetails = document.getElementById('currentVoiceDetails');
|
| 471 |
+
const playVoiceSample = document.getElementById('playVoiceSample');
|
| 472 |
+
const synthesisStatus = document.getElementById('synthesisStatus');
|
| 473 |
const currentTime = document.getElementById('currentTime');
|
| 474 |
const totalTime = document.getElementById('totalTime');
|
| 475 |
const progressContainer = document.getElementById('progressContainer');
|
|
|
|
| 531 |
pitchSelect.addEventListener('change', updateCostEstimator);
|
| 532 |
|
| 533 |
document.getElementById('showCostBreakdown')?.addEventListener('click', showCostBreakdownModal);
|
| 534 |
+
|
| 535 |
+
// Play voice sample event
|
| 536 |
+
playVoiceSample.addEventListener('click', () => {
|
| 537 |
+
if (selectedVoice) {
|
| 538 |
+
playSample(selectedVoice);
|
| 539 |
+
}
|
| 540 |
+
});
|
| 541 |
}
|
| 542 |
|
| 543 |
// Voice type and pricing functions
|
|
|
|
| 604 |
}
|
| 605 |
}
|
| 606 |
|
| 607 |
+
// Find and select default STANDARD male voice
|
| 608 |
+
function selectDefaultStandardMaleVoice() {
|
| 609 |
if (!availableVoices.length) return;
|
| 610 |
|
| 611 |
+
// Only look for Standard voices
|
| 612 |
+
const standardVoices = availableVoices.filter(v => v.name.includes('Standard'));
|
| 613 |
+
|
| 614 |
+
// Try to find preferred male voices in order
|
| 615 |
+
for (const preferredVoice of defaultStandardMaleVoices) {
|
| 616 |
+
const voice = standardVoices.find(v => v.name === preferredVoice);
|
| 617 |
if (voice) {
|
| 618 |
selectVoice(voice);
|
| 619 |
return voice;
|
| 620 |
}
|
| 621 |
}
|
| 622 |
|
| 623 |
+
// Fallback to first standard male voice
|
| 624 |
+
const standardMaleVoice = standardVoices.find(v => v.ssmlGender === 'MALE');
|
| 625 |
+
if (standardMaleVoice) {
|
| 626 |
+
selectVoice(standardMaleVoice);
|
| 627 |
+
return standardMaleVoice;
|
| 628 |
}
|
| 629 |
|
| 630 |
+
// Final fallback to any standard voice
|
| 631 |
+
if (standardVoices.length > 0) {
|
| 632 |
+
selectVoice(standardVoices[0]);
|
| 633 |
+
return standardVoices[0];
|
| 634 |
}
|
| 635 |
|
| 636 |
return null;
|
|
|
|
| 652 |
case 'neural2':
|
| 653 |
preferredVoices = availableVoices.filter(v => v.name.includes('Neural2'));
|
| 654 |
break;
|
| 655 |
+
case 'standard':
|
| 656 |
+
preferredVoices = availableVoices.filter(v => v.name.includes('Standard'));
|
| 657 |
+
break;
|
| 658 |
}
|
| 659 |
|
| 660 |
if (preferredVoices.length > 0) {
|
|
|
|
| 682 |
<div class="space-y-3">
|
| 683 |
<div class="bg-green-50 p-3 rounded border border-green-200">
|
| 684 |
<div class="flex justify-between items-center">
|
| 685 |
+
<span class="font-medium text-green-800">Standard Voices (Default)</span>
|
| 686 |
<span class="text-green-900 font-bold">$4.00</span>
|
| 687 |
</div>
|
| 688 |
<div class="text-sm text-green-600">per million characters</div>
|
|
|
|
| 873 |
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
| 874 |
}
|
| 875 |
|
| 876 |
+
// Update synthesis status
|
| 877 |
+
function updateSynthesisStatus() {
|
| 878 |
+
if (!selectedVoice) {
|
| 879 |
+
synthesisStatus.textContent = 'Please select an API key to load voices';
|
| 880 |
+
return;
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
if (!currentText.trim()) {
|
| 884 |
+
synthesisStatus.textContent = 'Add text to synthesize';
|
| 885 |
+
return;
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
const voiceType = getVoiceTypeAndPricing(selectedVoice.name);
|
| 889 |
+
const estimation = calculateEstimatedCost(currentText.length, voiceType);
|
| 890 |
+
synthesisStatus.textContent = `Ready (${currentText.length} chars, ~$${estimation.cost.toFixed(4)})`;
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
// API Key Management
|
| 894 |
saveKeyBtn.addEventListener('click', async () => {
|
| 895 |
const key = apiKeyInput.value ? apiKeyInput.value.trim() : '';
|
|
|
|
| 942 |
// Load available voices from Google TTS
|
| 943 |
async function loadVoices() {
|
| 944 |
if (!apiKey) {
|
| 945 |
+
currentVoiceName.textContent = 'No API key provided';
|
| 946 |
+
currentVoiceDetails.textContent = 'Please enter your Google Cloud API key above';
|
| 947 |
+
playVoiceSample.disabled = true;
|
| 948 |
return;
|
| 949 |
}
|
| 950 |
|
| 951 |
+
currentVoiceName.textContent = 'Loading voices...';
|
| 952 |
+
currentVoiceDetails.textContent = 'Please wait while voices are loaded';
|
| 953 |
+
playVoiceSample.disabled = true;
|
| 954 |
|
| 955 |
try {
|
| 956 |
const languageCode = languageSelect.value;
|
|
|
|
| 974 |
throw new Error('Invalid response from Google TTS API - no voices returned');
|
| 975 |
}
|
| 976 |
|
| 977 |
+
// Store all voices for model switching
|
| 978 |
+
availableVoices = data.voices;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
|
| 980 |
+
// Always start with Standard voices (as per user request)
|
| 981 |
+
selectDefaultStandardMaleVoice();
|
| 982 |
updateCostEstimator();
|
| 983 |
+
updateSynthesisStatus();
|
| 984 |
|
| 985 |
+
showToast(`Loaded ${availableVoices.length} voices successfully`, 'success');
|
| 986 |
|
| 987 |
} catch (error) {
|
| 988 |
console.error('Error loading voices:', error);
|
| 989 |
+
currentVoiceName.textContent = 'Error loading voices';
|
| 990 |
+
currentVoiceDetails.textContent = error.message;
|
| 991 |
+
showToast('Failed to load voices: ' + error.message, 'error');
|
| 992 |
}
|
| 993 |
}
|
| 994 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
function selectVoice(voice) {
|
| 996 |
selectedVoice = voice;
|
|
|
|
| 997 |
|
| 998 |
+
// Update voice display
|
| 999 |
+
const displayName = voice.name.replace(/en-US-|en-GB-|en-AU-/, '').replace(/-/g, ' ');
|
| 1000 |
+
currentVoiceName.textContent = displayName;
|
| 1001 |
+
|
| 1002 |
+
const voiceType = getVoiceTypeAndPricing(voice.name);
|
| 1003 |
+
const genderIcon = voice.ssmlGender === 'MALE' ? '♂' : voice.ssmlGender === 'FEMALE' ? '♀' : '○';
|
| 1004 |
+
currentVoiceDetails.textContent = `${genderIcon} ${voice.ssmlGender} • ${voiceType.name} Voice • $${voiceType.rate}/1M chars`;
|
|
|
|
| 1005 |
|
| 1006 |
+
playVoiceSample.disabled = false;
|
| 1007 |
updateCostEstimator();
|
| 1008 |
+
updateSynthesisStatus();
|
| 1009 |
+
|
| 1010 |
+
showToast(`Selected: ${voice.name}`, 'success', 1500);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1011 |
}
|
| 1012 |
|
| 1013 |
// Play voice sample
|
|
|
|
| 1024 |
try {
|
| 1025 |
showSynthesisProgress(0);
|
| 1026 |
const sampleText = "Hello, this is a sample of my voice. I can read your documents with natural sounding speech.";
|
| 1027 |
+
const audioData = await synthesizeSpeech(sampleText, voice, 1, 0, modelSelect.value);
|
| 1028 |
hideSynthesisProgress();
|
| 1029 |
playAudioData(audioData);
|
| 1030 |
} catch (error) {
|
|
|
|
| 1649 |
for (let i = 1; i <= Math.min(5, lines.length - 1); i++) {
|
| 1650 |
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
| 1651 |
text += `Row ${i}: ${values.join(', ')}\n`;
|
| 1652 |
+
</div>
|
| 1653 |
}
|
| 1654 |
|
| 1655 |
setDocumentContent(text);
|
|
|
|
| 1672 |
}
|
| 1673 |
|
| 1674 |
updateCostEstimator();
|
| 1675 |
+
updateSynthesisStatus();
|
| 1676 |
}
|
| 1677 |
|
| 1678 |
// Playback controls
|
| 1679 |
playBtn.addEventListener('click', async () => {
|
| 1680 |
if (!selectedVoice) {
|
| 1681 |
+
showToast('Please wait for voices to load or check your API key', 'error');
|
| 1682 |
return;
|
| 1683 |
}
|
| 1684 |
|