amauricunha commited on
Commit
ba185bd
·
verified ·
1 Parent(s): 5d57748

Create index.html

Browse files
Files changed (1) hide show
  1. index.html +390 -0
index.html ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>TTS Language Study Tool</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
10
+ body {
11
+ font-family: 'Inter', sans-serif;
12
+ background-color: #f7f7f7;
13
+ display: flex;
14
+ justify-content: center;
15
+ align-items: center;
16
+ min-height: 100vh;
17
+ padding: 1rem;
18
+ }
19
+ .container {
20
+ max-width: 700px;
21
+ width: 100%;
22
+ background-color: #ffffff;
23
+ padding: 2.5rem;
24
+ border-radius: 16px;
25
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
26
+ }
27
+ button:disabled {
28
+ opacity: 0.5;
29
+ cursor: not-allowed;
30
+ }
31
+ #audioPlayer {
32
+ width: 100%;
33
+ margin-top: 1.5rem;
34
+ }
35
+ select, textarea {
36
+ border: 1px solid #d1d5db;
37
+ border-radius: 8px;
38
+ padding: 0.75rem;
39
+ color: #1f2937;
40
+ transition: border-color 0.2s, box-shadow 0.2s;
41
+ }
42
+ select:focus, textarea:focus {
43
+ border-color: #4f46e5;
44
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
45
+ outline: none;
46
+ }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <div class="container">
51
+ <h1 class="text-3xl font-bold text-gray-800 mb-6 border-b-2 pb-2 border-indigo-500">
52
+ Text-to-Speech Language Study Tool
53
+ </h1>
54
+ <p class="text-gray-600 mb-6">
55
+ Paste your English text below to practice listening comprehension and pronunciation.
56
+ </p>
57
+
58
+ <div class="grid grid-cols-2 gap-4 mb-4">
59
+ <div>
60
+ <label for="voiceSelector" class="block text-sm font-medium text-gray-700 mb-2">Select Speaker:</label>
61
+ <select id="voiceSelector" class="w-full">
62
+ <option value="Puck">👩 Woman (Puck - Upbeat, US)</option>
63
+ <option value="Wren">👨 Man (Wren - Clear, US)</option>
64
+ <option value="Aoede">🗣️ Woman (Aoede - Breezy, UK)</option>
65
+ </select>
66
+ </div>
67
+ <div>
68
+ <label for="speedSelector" class="block text-sm font-medium text-gray-700 mb-2">Playback Speed:</label>
69
+ <select id="speedSelector" class="w-full">
70
+ <option value="1.0">1.0x (Normal)</option>
71
+ <option value="0.75">0.75x (Slower)</option>
72
+ <option value="0.5">0.5x (Slowest - for comprehension)</option>
73
+ <option value="1.25">1.25x (Faster)</option>
74
+ <option value="1.5">1.5x (Fastest - for speed listening)</option>
75
+ </select>
76
+ </div>
77
+ </div>
78
+
79
+ <div class="mb-4">
80
+ <label for="transcriptArea" class="block text-sm font-medium text-gray-700 mb-2">Paste English Text Here:</label>
81
+ <textarea id="transcriptArea" rows="8" class="w-full resize-none" placeholder="Paste the text you want to practice listening to. (Max 10,000 characters)"></textarea>
82
+ <div id="charCount" class="text-xs text-gray-500 mt-1 text-right">0 / 10000 Characters</div>
83
+ </div>
84
+
85
+ <div id="status" class="mb-4 text-center p-3 rounded-lg text-sm transition duration-300 bg-gray-100 text-gray-700">
86
+ Enter text and click 'Generate & Play'.
87
+ </div>
88
+
89
+ <button id="generateButton"
90
+ class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-4 rounded-lg transition duration-300 ease-in-out shadow-lg transform hover:scale-[1.01] flex items-center justify-center">
91
+ <svg id="speakerIcon" class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l3.586-3.586a1 1 0 011.414 0L12 9l-3 3 3 3-1.414 1.414a1 1 0 01-1.414 0L5.586 15z"></path></svg>
92
+ Generate & Play Audio
93
+ </button>
94
+
95
+ <!-- Setting controls to show the playback speed option -->
96
+ <audio id="audioPlayer" controls hidden></audio>
97
+
98
+ <!-- Queue for chunked audio playback -->
99
+ <div id="audioQueueContainer" hidden></div>
100
+ </div>
101
+
102
+ <script type="module">
103
+ const generateButton = document.getElementById('generateButton');
104
+ const audioPlayer = document.getElementById('audioPlayer');
105
+ const statusDiv = document.getElementById('status');
106
+ const transcriptArea = document.getElementById('transcriptArea');
107
+ const voiceSelector = document.getElementById('voiceSelector');
108
+ const speedSelector = document.getElementById('speedSelector');
109
+ const charCountDiv = document.getElementById('charCount');
110
+ const audioQueueContainer = document.getElementById('audioQueueContainer');
111
+
112
+ const MAX_CHARS = 10000;
113
+ const CHUNK_SIZE = 1800; // Chunk size is still dictated by the API limit
114
+ let audioQueue = [];
115
+
116
+ // --- Utility Functions for Audio Generation ---
117
+
118
+ function base64ToArrayBuffer(base64) {
119
+ const binaryString = atob(base64);
120
+ const len = binaryString.length;
121
+ const bytes = new Uint8Array(len);
122
+ for (let i = 0; i < len; i++) {
123
+ bytes[i] = binaryString.charCodeAt(i);
124
+ }
125
+ return bytes.buffer;
126
+ }
127
+
128
+ function pcmToWav(pcm16, sampleRate = 16000) {
129
+ const numChannels = 1;
130
+ const bytesPerSample = 2;
131
+ const dataLength = pcm16.length * bytesPerSample;
132
+ const buffer = new ArrayBuffer(44 + dataLength);
133
+ const view = new DataView(buffer);
134
+
135
+ function writeString(view, offset, string) {
136
+ for (let i = 0; i < string.length; i++) {
137
+ view.setUint8(offset + i, string.charCodeAt(i));
138
+ }
139
+ }
140
+
141
+ // RIFF chunk
142
+ writeString(view, 0, 'RIFF');
143
+ view.setUint32(4, 36 + dataLength, true);
144
+ writeString(view, 8, 'WAVE');
145
+
146
+ // FMT sub-chunk
147
+ writeString(view, 12, 'fmt ');
148
+ view.setUint32(16, 16, true);
149
+ view.setUint16(20, 1, true);
150
+ view.setUint16(22, numChannels, true);
151
+ view.setUint32(24, sampleRate, true);
152
+ view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
153
+ view.setUint16(32, numChannels * bytesPerSample, true);
154
+ view.setUint16(34, 16, true);
155
+
156
+ // Data sub-chunk
157
+ writeString(view, 36, 'data');
158
+ view.setUint32(40, dataLength, true);
159
+
160
+ // Write PCM data
161
+ let offset = 44;
162
+ for (let i = 0; i < pcm16.length; i++) {
163
+ view.setInt16(offset, pcm16[i], true);
164
+ offset += 2;
165
+ }
166
+
167
+ return new Blob([buffer], { type: 'audio/wav' });
168
+ }
169
+
170
+ // ** UPDATED fetchAudio to call the local proxy endpoint **
171
+ async function fetchAudio(prompt, voice, retries = 5) {
172
+ // The API call is now directed to the local backend proxy
173
+ const proxyUrl = "/tts-proxy";
174
+
175
+ const payload = {
176
+ prompt: prompt,
177
+ voice: voice
178
+ };
179
+
180
+ for (let i = 0; i < retries; i++) {
181
+ try {
182
+ const response = await fetch(proxyUrl, {
183
+ method: 'POST',
184
+ headers: { 'Content-Type': 'application/json' },
185
+ body: JSON.stringify(payload)
186
+ });
187
+
188
+ const result = await response.json();
189
+
190
+ if (!response.ok) {
191
+ // Handle 429/rate limiting from proxy
192
+ if (response.status === 429 && i < retries - 1) {
193
+ const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
194
+ await new Promise(resolve => setTimeout(resolve, delay));
195
+ continue;
196
+ }
197
+ const errorMessage = result.error || `Proxy request failed with status: ${response.status}`;
198
+ throw new Error(errorMessage);
199
+ }
200
+
201
+ // The proxy returns { audioData: base64_string }
202
+ const audioData = result.audioData;
203
+
204
+ if (audioData) {
205
+ // Assuming 16000Hz rate and L16 format from the API
206
+ const sampleRate = 16000;
207
+ const pcmData = base64ToArrayBuffer(audioData);
208
+ const pcm16 = new Int16Array(pcmData);
209
+ const wavBlob = pcmToWav(pcm16, sampleRate);
210
+ const audioUrl = URL.createObjectURL(wavBlob);
211
+
212
+ return audioUrl;
213
+ } else {
214
+ throw new Error("Missing audio data in proxy response.");
215
+ }
216
+
217
+ } catch (error) {
218
+ console.error("Fetch attempt failed:", error);
219
+ if (i === retries - 1) throw error;
220
+ }
221
+ }
222
+ }
223
+
224
+ // --- Text Chunking and Audio Concatenation Logic (Unchanged, as it handles the 10k limit) ---
225
+
226
+ function getChunks(text) {
227
+ const chunks = [];
228
+ let sentences = text.match(/[^.!?\n]+[.!?\n]*/g) || [text];
229
+ let currentChunk = '';
230
+
231
+ for (const sentence of sentences) {
232
+ if (currentChunk.length + sentence.length > CHUNK_SIZE) {
233
+ if (currentChunk.length > 0) {
234
+ chunks.push(currentChunk.trim());
235
+ currentChunk = sentence;
236
+ } else {
237
+ let remaining = sentence;
238
+ while (remaining.length > 0) {
239
+ chunks.push(remaining.substring(0, CHUNK_SIZE).trim());
240
+ remaining = remaining.substring(CHUNK_SIZE);
241
+ }
242
+ currentChunk = '';
243
+ }
244
+ } else {
245
+ currentChunk += sentence;
246
+ }
247
+ }
248
+
249
+ if (currentChunk.length > 0) {
250
+ chunks.push(currentChunk.trim());
251
+ }
252
+
253
+ return chunks.filter(c => c.length > 0);
254
+ }
255
+
256
+ async function generateChunkedAudio(prompt, voice) {
257
+ const chunks = getChunks(prompt);
258
+ const generatedUrls = [];
259
+
260
+ for (let i = 0; i < chunks.length; i++) {
261
+ statusDiv.textContent = `Generating audio chunk ${i + 1} of ${chunks.length}...`;
262
+ try {
263
+ const url = await fetchAudio(chunks[i], voice);
264
+ generatedUrls.push(url);
265
+ } catch (error) {
266
+ statusDiv.textContent = `Error on chunk ${i + 1}. Details in console.`;
267
+ throw error;
268
+ }
269
+ }
270
+ return generatedUrls;
271
+ }
272
+
273
+ function setupPlaybackQueue(urls) {
274
+ audioQueue.forEach(a => {
275
+ if (a.src) URL.revokeObjectURL(a.src);
276
+ a.remove();
277
+ });
278
+ audioQueue = [];
279
+ audioQueueContainer.innerHTML = '';
280
+
281
+ audioPlayer.hidden = true;
282
+ audioPlayer.removeAttribute('src');
283
+
284
+ if (urls.length === 0) return;
285
+
286
+ audioPlayer.src = urls[0];
287
+ audioPlayer.hidden = false;
288
+ audioPlayer.playbackRate = parseFloat(speedSelector.value);
289
+
290
+ if (urls.length > 1) {
291
+ let currentAudio = audioPlayer;
292
+ for (let i = 1; i < urls.length; i++) {
293
+ const nextAudio = document.createElement('audio');
294
+ nextAudio.src = urls[i];
295
+ nextAudio.playbackRate = parseFloat(speedSelector.value);
296
+ audioQueueContainer.appendChild(nextAudio);
297
+ audioQueue.push(nextAudio);
298
+
299
+ currentAudio.addEventListener('ended', () => {
300
+ nextAudio.play();
301
+ }, { once: true });
302
+
303
+ currentAudio = nextAudio;
304
+ }
305
+ }
306
+
307
+ const allPlayers = [audioPlayer, ...audioQueue];
308
+ speedSelector.onchange = () => {
309
+ const newRate = parseFloat(speedSelector.value);
310
+ allPlayers.forEach(p => p.playbackRate = newRate);
311
+ };
312
+ }
313
+
314
+
315
+ // --- Event Handlers ---
316
+
317
+ function updateCharCount() {
318
+ const text = transcriptArea.value;
319
+ const count = text.length;
320
+ charCountDiv.textContent = `${count} / ${MAX_CHARS} Characters`;
321
+
322
+ if (count > MAX_CHARS) {
323
+ transcriptArea.value = text.substring(0, MAX_CHARS);
324
+ charCountDiv.classList.add('text-red-600');
325
+ generateButton.disabled = true;
326
+ statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-red-100 text-red-800 transition duration-300';
327
+ statusDiv.textContent = `Text exceeds the ${MAX_CHARS} character limit.`;
328
+ } else if (count === 0) {
329
+ generateButton.disabled = true;
330
+ charCountDiv.classList.remove('text-red-600');
331
+ statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-gray-100 text-gray-700 transition duration-300';
332
+ statusDiv.textContent = 'Enter text and click "Generate & Play".';
333
+ } else {
334
+ generateButton.disabled = false;
335
+ charCountDiv.classList.remove('text-red-600');
336
+ statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-indigo-100 text-indigo-800 transition duration-300';
337
+ statusDiv.textContent = 'Text ready to generate.';
338
+ }
339
+
340
+ audioPlayer.hidden = true;
341
+ audioPlayer.removeAttribute('src');
342
+ }
343
+
344
+ speedSelector.addEventListener('change', () => {
345
+ if (audioPlayer.src && audioQueue.length === 0) {
346
+ audioPlayer.playbackRate = parseFloat(speedSelector.value);
347
+ }
348
+ });
349
+
350
+ window.onload = updateCharCount;
351
+ transcriptArea.addEventListener('input', updateCharCount);
352
+
353
+
354
+ generateButton.addEventListener('click', async () => {
355
+ const prompt = transcriptArea.value.trim();
356
+ const voice = voiceSelector.value;
357
+ const voiceName = voiceSelector.options[voiceSelector.selectedIndex].text;
358
+
359
+ if (prompt.length === 0) {
360
+ statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-red-100 text-red-800 transition duration-300';
361
+ statusDiv.textContent = 'Please paste or type text before generating audio.';
362
+ return;
363
+ }
364
+
365
+ setupPlaybackQueue([]);
366
+ generateButton.disabled = true;
367
+ statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-yellow-100 text-yellow-800 transition duration-300';
368
+ statusDiv.textContent = `Starting generation with ${voiceName} voice...`;
369
+
370
+ try {
371
+ const audioUrls = await generateChunkedAudio(prompt, voice);
372
+
373
+ setupPlaybackQueue(audioUrls);
374
+ // We use setTimeout to ensure the HTML element is ready before attempting play
375
+ setTimeout(() => audioPlayer.play(), 100);
376
+
377
+ statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-green-100 text-green-800 transition duration-300';
378
+ statusDiv.textContent = `Audio generated successfully in ${audioUrls.length} chunks. Playing automatically.`;
379
+
380
+ } catch (error) {
381
+ statusDiv.className = 'mb-4 text-center p-3 rounded-lg text-sm bg-red-100 text-red-800 transition duration-300';
382
+ statusDiv.textContent = `Error generating audio: ${error.message}`;
383
+ console.error("Full TTS generation error:", error);
384
+ } finally {
385
+ generateButton.disabled = false;
386
+ }
387
+ });
388
+ </script>
389
+ </body>
390
+ </html>