siyah1 commited on
Commit
6fef8c4
·
verified ·
1 Parent(s): 5995d52

Create index.html

Browse files
Files changed (1) hide show
  1. index.html +555 -0
index.html ADDED
@@ -0,0 +1,555 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Gemini Voice Chat</title>
8
+ <style>
9
+ :root {
10
+ --color-accent: #6366f1;
11
+ --color-background: #0f172a;
12
+ --color-surface: #1e293b;
13
+ --color-text: #e2e8f0;
14
+ --boxSize: 8px;
15
+ --gutter: 4px;
16
+ }
17
+ body {
18
+ margin: 0;
19
+ padding: 0;
20
+ background-color: var(--color-background);
21
+ color: var(--color-text);
22
+ font-family: system-ui, -apple-system, sans-serif;
23
+ min-height: 100vh;
24
+ display: flex;
25
+ flex-direction: column;
26
+ align-items: center;
27
+ justify-content: center;
28
+ }
29
+ .container {
30
+ width: 90%;
31
+ max-width: 800px;
32
+ background-color: var(--color-surface);
33
+ padding: 2rem;
34
+ border-radius: 1rem;
35
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
36
+ }
37
+ .wave-container {
38
+ position: relative;
39
+ display: flex;
40
+ min-height: 100px;
41
+ max-height: 128px;
42
+ justify-content: center;
43
+ align-items: center;
44
+ margin: 2rem 0;
45
+ }
46
+ .box-container {
47
+ display: flex;
48
+ justify-content: space-between;
49
+ height: 64px;
50
+ width: 100%;
51
+ }
52
+ .box {
53
+ height: 100%;
54
+ width: var(--boxSize);
55
+ background: var(--color-accent);
56
+ border-radius: 8px;
57
+ transition: transform 0.05s ease;
58
+ }
59
+ .controls {
60
+ display: grid;
61
+ gap: 1rem;
62
+ margin-bottom: 2rem;
63
+ }
64
+ .input-group {
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: 0.5rem;
68
+ }
69
+ label {
70
+ font-size: 0.875rem;
71
+ font-weight: 500;
72
+ }
73
+ select {
74
+ padding: 0.75rem;
75
+ border-radius: 0.5rem;
76
+ border: 1px solid rgba(255, 255, 255, 0.1);
77
+ background-color: var(--color-background);
78
+ color: var(--color-text);
79
+ font-size: 1rem;
80
+ }
81
+ button {
82
+ padding: 1rem 2rem;
83
+ border-radius: 0.5rem;
84
+ border: none;
85
+ background-color: var(--color-accent);
86
+ color: white;
87
+ font-weight: 600;
88
+ cursor: pointer;
89
+ transition: all 0.2s ease;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ gap: 12px;
94
+ min-width: 180px;
95
+ }
96
+ button:hover {
97
+ opacity: 0.9;
98
+ transform: translateY(-1px);
99
+ }
100
+ .icon-with-spinner {
101
+ display: flex;
102
+ align-items: center;
103
+ justify-content: center;
104
+ gap: 12px;
105
+ min-width: 180px;
106
+ }
107
+ .spinner {
108
+ width: 20px;
109
+ height: 20px;
110
+ border: 2px solid white;
111
+ border-top-color: transparent;
112
+ border-radius: 50%;
113
+ animation: spin 1s linear infinite;
114
+ flex-shrink: 0;
115
+ }
116
+ @keyframes spin {
117
+ to {
118
+ transform: rotate(360deg);
119
+ }
120
+ }
121
+ .pulse-container {
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ gap: 12px;
126
+ }
127
+ .pulse-circle {
128
+ width: 20px;
129
+ height: 20px;
130
+ border-radius: 50%;
131
+ background-color: white;
132
+ opacity: 0.2;
133
+ flex-shrink: 0;
134
+ transform: translateX(-0%) scale(var(--audio-level, 1));
135
+ transition: transform 0.1s ease;
136
+ }
137
+ .toast {
138
+ position: fixed;
139
+ top: 20px;
140
+ left: 50%;
141
+ transform: translateX(-50%);
142
+ padding: 16px 24px;
143
+ border-radius: 4px;
144
+ font-size: 14px;
145
+ z-index: 1000;
146
+ display: none;
147
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
148
+ }
149
+ .toast.error {
150
+ background-color: #f44336;
151
+ color: white;
152
+ }
153
+ .toast.warning {
154
+ background-color: #ffd700;
155
+ color: black;
156
+ }
157
+ .mute-toggle {
158
+ width: 24px;
159
+ height: 24px;
160
+ cursor: pointer;
161
+ flex-shrink: 0;
162
+ }
163
+ .mute-toggle svg {
164
+ display: block;
165
+ }
166
+ #start-button {
167
+ margin-left: auto;
168
+ margin-right: auto;
169
+ }
170
+ .api-status {
171
+ background-color: var(--color-background);
172
+ padding: 1rem;
173
+ border-radius: 0.5rem;
174
+ margin-bottom: 1rem;
175
+ text-align: center;
176
+ border: 1px solid rgba(34, 197, 94, 0.3);
177
+ }
178
+ .api-status.success {
179
+ color: #22c55e;
180
+ }
181
+ .api-status.error {
182
+ color: #ef4444;
183
+ border-color: rgba(239, 68, 68, 0.3);
184
+ }
185
+ </style>
186
+ </head>
187
+
188
+ <body>
189
+ <div id="error-toast" class="toast"></div>
190
+ <div style="text-align: center">
191
+ <h1>Gemini Voice Chat</h1>
192
+ <p>Speak with Gemini using real-time audio streaming</p>
193
+ <p>
194
+ Using API key from environment variable
195
+ </p>
196
+ </div>
197
+ <div class="container">
198
+ <div class="api-status success">
199
+ ✓ API Key configured via environment variable
200
+ </div>
201
+
202
+ <div class="controls">
203
+ <div class="input-group">
204
+ <label for="voice">Voice</label>
205
+ <select id="voice">
206
+ <option value="Puck">Puck</option>
207
+ <option value="Charon">Charon</option>
208
+ <option value="Kore">Kore</option>
209
+ <option value="Fenrir">Fenrir</option>
210
+ <option value="Aoede">Aoede</option>
211
+ </select>
212
+ </div>
213
+ </div>
214
+
215
+ <div class="wave-container">
216
+ <div class="box-container">
217
+ <!-- Boxes will be dynamically added here -->
218
+ </div>
219
+ </div>
220
+
221
+ <button id="start-button">Start Recording</button>
222
+ </div>
223
+
224
+ <audio id="audio-output"></audio>
225
+
226
+ <script>
227
+ let peerConnection;
228
+ let audioContext;
229
+ let dataChannel;
230
+ let isRecording = false;
231
+ let webrtc_id;
232
+ let isMuted = false;
233
+ let analyser_input, dataArray_input;
234
+ let analyser, dataArray;
235
+ let source_input = null;
236
+ let source_output = null;
237
+ const startButton = document.getElementById('start-button');
238
+ const voiceSelect = document.getElementById('voice');
239
+ const audioOutput = document.getElementById('audio-output');
240
+ const boxContainer = document.querySelector('.box-container');
241
+ const numBars = 32;
242
+ for (let i = 0; i < numBars; i++) {
243
+ const box = document.createElement('div');
244
+ box.className = 'box';
245
+ boxContainer.appendChild(box);
246
+ }
247
+
248
+ // SVG Icons
249
+ const micIconSVG = `
250
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
251
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
252
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
253
+ <line x1="12" y1="19" x2="12" y2="23"></line>
254
+ <line x1="8" y1="23" x2="16" y2="23"></line>
255
+ </svg>`;
256
+ const micMutedIconSVG = `
257
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
258
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
259
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
260
+ <line x1="12" y1="19" x2="12" y2="23"></line>
261
+ <line x1="8" y1="23" x2="16" y2="23"></line>
262
+ <line x1="1" y1="1" x2="23" y2="23"></line>
263
+ </svg>`;
264
+
265
+ function updateButtonState() {
266
+ startButton.innerHTML = '';
267
+ startButton.onclick = null;
268
+ if (peerConnection && (peerConnection.connectionState === 'connecting' || peerConnection.connectionState === 'new')) {
269
+ startButton.innerHTML = `
270
+ <div class="icon-with-spinner">
271
+ <div class="spinner"></div>
272
+ <span>Connecting...</span>
273
+ </div>
274
+ `;
275
+ startButton.disabled = true;
276
+ } else if (peerConnection && peerConnection.connectionState === 'connected') {
277
+ const pulseContainer = document.createElement('div');
278
+ pulseContainer.className = 'pulse-container';
279
+ pulseContainer.innerHTML = `
280
+ <div class="pulse-circle"></div>
281
+ <span>Stop Recording</span>
282
+ `;
283
+ const muteToggle = document.createElement('div');
284
+ muteToggle.className = 'mute-toggle';
285
+ muteToggle.title = isMuted ? 'Unmute' : 'Mute';
286
+ muteToggle.innerHTML = isMuted ? micMutedIconSVG : micIconSVG;
287
+ muteToggle.addEventListener('click', toggleMute);
288
+ startButton.appendChild(pulseContainer);
289
+ startButton.appendChild(muteToggle);
290
+ startButton.disabled = false;
291
+ } else {
292
+ startButton.innerHTML = 'Start Recording';
293
+ startButton.disabled = false;
294
+ }
295
+ }
296
+
297
+ function showError(message) {
298
+ const toast = document.getElementById('error-toast');
299
+ toast.textContent = message;
300
+ toast.className = 'toast error';
301
+ toast.style.display = 'block';
302
+ setTimeout(() => {
303
+ toast.style.display = 'none';
304
+ }, 5000);
305
+ }
306
+
307
+ function toggleMute(event) {
308
+ event.stopPropagation();
309
+ if (!peerConnection || peerConnection.connectionState !== 'connected') return;
310
+ isMuted = !isMuted;
311
+ console.log("Mute toggled:", isMuted);
312
+ peerConnection.getSenders().forEach(sender => {
313
+ if (sender.track && sender.track.kind === 'audio') {
314
+ sender.track.enabled = !isMuted;
315
+ console.log(`Audio track ${sender.track.id} enabled: ${!isMuted}`);
316
+ }
317
+ });
318
+ updateButtonState();
319
+ }
320
+
321
+ async function setupWebRTC() {
322
+ const config = __RTC_CONFIGURATION__;
323
+ peerConnection = new RTCPeerConnection(config);
324
+ webrtc_id = Math.random().toString(36).substring(7);
325
+ const timeoutId = setTimeout(() => {
326
+ const toast = document.getElementById('error-toast');
327
+ toast.textContent = "Connection is taking longer than usual. Are you on a VPN?";
328
+ toast.className = 'toast warning';
329
+ toast.style.display = 'block';
330
+ setTimeout(() => {
331
+ toast.style.display = 'none';
332
+ }, 5000);
333
+ }, 5000);
334
+ try {
335
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
336
+ stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
337
+ if (!audioContext || audioContext.state === 'closed') {
338
+ audioContext = new AudioContext();
339
+ }
340
+ if (source_input) {
341
+ try { source_input.disconnect(); } catch (e) { console.warn("Error disconnecting previous input source:", e); }
342
+ source_input = null;
343
+ }
344
+ source_input = audioContext.createMediaStreamSource(stream);
345
+ analyser_input = audioContext.createAnalyser();
346
+ source_input.connect(analyser_input);
347
+ analyser_input.fftSize = 64;
348
+ dataArray_input = new Uint8Array(analyser_input.frequencyBinCount);
349
+ updateAudioLevel();
350
+ peerConnection.addEventListener('connectionstatechange', () => {
351
+ console.log('connectionstatechange', peerConnection.connectionState);
352
+ if (peerConnection.connectionState === 'connected') {
353
+ clearTimeout(timeoutId);
354
+ const toast = document.getElementById('error-toast');
355
+ toast.style.display = 'none';
356
+ if (analyser_input) updateAudioLevel();
357
+ if (analyser) updateVisualization();
358
+ }
359
+ updateButtonState();
360
+ });
361
+ peerConnection.onicecandidate = ({ candidate }) => {
362
+ if (candidate) {
363
+ console.debug("Sending ICE candidate", candidate);
364
+ fetch('/webrtc/offer', {
365
+ method: 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: JSON.stringify({
368
+ candidate: candidate.toJSON(),
369
+ webrtc_id: webrtc_id,
370
+ type: "ice-candidate",
371
+ })
372
+ })
373
+ }
374
+ };
375
+ peerConnection.addEventListener('track', (evt) => {
376
+ if (evt.track.kind === 'audio' && audioOutput) {
377
+ if (audioOutput.srcObject !== evt.streams[0]) {
378
+ audioOutput.srcObject = evt.streams[0];
379
+ audioOutput.play().catch(e => console.error("Audio play failed:", e));
380
+ if (!audioContext || audioContext.state === 'closed') {
381
+ console.warn("AudioContext not ready for output track analysis.");
382
+ return;
383
+ }
384
+ if (source_output) {
385
+ try { source_output.disconnect(); } catch (e) { console.warn("Error disconnecting previous output source:", e); }
386
+ source_output = null;
387
+ }
388
+ source_output = audioContext.createMediaStreamSource(evt.streams[0]);
389
+ analyser = audioContext.createAnalyser();
390
+ source_output.connect(analyser);
391
+ analyser.fftSize = 2048;
392
+ dataArray = new Uint8Array(analyser.frequencyBinCount);
393
+ updateVisualization();
394
+ }
395
+ }
396
+ });
397
+ dataChannel = peerConnection.createDataChannel('text');
398
+ dataChannel.onmessage = (event) => {
399
+ const eventJson = JSON.parse(event.data);
400
+ if (eventJson.type === "error") {
401
+ showError(eventJson.message);
402
+ } else if (eventJson.type === "send_input") {
403
+ fetch('/input_hook', {
404
+ method: 'POST',
405
+ headers: {
406
+ 'Content-Type': 'application/json',
407
+ },
408
+ body: JSON.stringify({
409
+ webrtc_id: webrtc_id,
410
+ voice_name: voiceSelect.value
411
+ // Removed api_key from the request
412
+ })
413
+ });
414
+ }
415
+ };
416
+ const offer = await peerConnection.createOffer();
417
+ await peerConnection.setLocalDescription(offer);
418
+ const response = await fetch('/webrtc/offer', {
419
+ method: 'POST',
420
+ headers: { 'Content-Type': 'application/json' },
421
+ body: JSON.stringify({
422
+ sdp: peerConnection.localDescription.sdp,
423
+ type: peerConnection.localDescription.type,
424
+ webrtc_id: webrtc_id,
425
+ })
426
+ });
427
+ const serverResponse = await response.json();
428
+ if (serverResponse.status === 'failed') {
429
+ showError(serverResponse.meta.error === 'concurrency_limit_reached'
430
+ ? `Too many connections. Maximum limit is ${serverResponse.meta.limit}`
431
+ : serverResponse.meta.error);
432
+ stopWebRTC();
433
+ startButton.textContent = 'Start Recording';
434
+ return;
435
+ }
436
+ await peerConnection.setRemoteDescription(serverResponse);
437
+ } catch (err) {
438
+ clearTimeout(timeoutId);
439
+ console.error('Error setting up WebRTC:', err);
440
+ showError('Failed to establish connection. Please try again.');
441
+ stopWebRTC();
442
+ startButton.textContent = 'Start Recording';
443
+ }
444
+ }
445
+
446
+ function updateVisualization() {
447
+ if (!analyser || !peerConnection || !['connected', 'connecting'].includes(peerConnection.connectionState)) {
448
+ const bars = document.querySelectorAll('.box');
449
+ bars.forEach(bar => bar.style.transform = 'scaleY(0.1)');
450
+ return;
451
+ }
452
+ analyser.getByteFrequencyData(dataArray);
453
+ const bars = document.querySelectorAll('.box');
454
+ for (let i = 0; i < bars.length; i++) {
455
+ const barHeight = (dataArray[i] / 255) * 2;
456
+ bars[i].style.transform = `scaleY(${Math.max(0.1, barHeight)})`;
457
+ }
458
+ requestAnimationFrame(updateVisualization);
459
+ }
460
+
461
+ function updateAudioLevel() {
462
+ if (!analyser_input || !peerConnection || !['connected', 'connecting'].includes(peerConnection.connectionState)) {
463
+ const pulseCircle = document.querySelector('.pulse-circle');
464
+ if (pulseCircle) {
465
+ pulseCircle.style.setProperty('--audio-level', 1);
466
+ }
467
+ return;
468
+ }
469
+ analyser_input.getByteFrequencyData(dataArray_input);
470
+ const average = Array.from(dataArray_input).reduce((a, b) => a + b, 0) / dataArray_input.length;
471
+ const audioLevel = average / 255;
472
+ const pulseCircle = document.querySelector('.pulse-circle');
473
+ if (pulseCircle) {
474
+ pulseCircle.style.setProperty('--audio-level', 1 + audioLevel);
475
+ }
476
+ requestAnimationFrame(updateAudioLevel);
477
+ }
478
+
479
+ function stopWebRTC() {
480
+ console.log("Running stopWebRTC");
481
+ if (peerConnection) {
482
+ peerConnection.getSenders().forEach(sender => {
483
+ if (sender.track) {
484
+ sender.track.stop();
485
+ }
486
+ });
487
+ peerConnection.ontrack = null;
488
+ peerConnection.onicegatheringstatechange = null;
489
+ peerConnection.onconnectionstatechange = null;
490
+ if (dataChannel) {
491
+ dataChannel.onmessage = null;
492
+ try { dataChannel.close(); } catch (e) { console.warn("Error closing data channel:", e); }
493
+ dataChannel = null;
494
+ }
495
+ try { peerConnection.close(); } catch (e) { console.warn("Error closing peer connection:", e); }
496
+ peerConnection = null;
497
+ }
498
+ if (audioOutput) {
499
+ audioOutput.pause();
500
+ audioOutput.srcObject = null;
501
+ }
502
+ if (source_input) {
503
+ try { source_input.disconnect(); } catch (e) { console.warn("Error disconnecting input source:", e); }
504
+ source_input = null;
505
+ }
506
+ if (source_output) {
507
+ try { source_output.disconnect(); } catch (e) { console.warn("Error disconnecting output source:", e); }
508
+ source_output = null;
509
+ }
510
+ if (audioContext && audioContext.state !== 'closed') {
511
+ audioContext.close().then(() => {
512
+ console.log("AudioContext closed successfully.");
513
+ audioContext = null;
514
+ }).catch(e => {
515
+ console.error("Error closing AudioContext:", e);
516
+ audioContext = null;
517
+ });
518
+ } else {
519
+ audioContext = null;
520
+ }
521
+ analyser_input = null;
522
+ dataArray_input = null;
523
+ analyser = null;
524
+ dataArray = null;
525
+ isMuted = false;
526
+ isRecording = false;
527
+ updateButtonState();
528
+ const bars = document.querySelectorAll('.box');
529
+ bars.forEach(bar => bar.style.transform = 'scaleY(0.1)');
530
+ const pulseCircle = document.querySelector('.pulse-circle');
531
+ if (pulseCircle) {
532
+ pulseCircle.style.setProperty('--audio-level', 1);
533
+ }
534
+ }
535
+
536
+ startButton.addEventListener('click', (event) => {
537
+ if (event.target.closest('.mute-toggle')) {
538
+ return;
539
+ }
540
+ if (peerConnection && peerConnection.connectionState === 'connected') {
541
+ console.log("Stop button clicked");
542
+ stopWebRTC();
543
+ } else if (!peerConnection || ['new', 'closed', 'failed', 'disconnected'].includes(peerConnection.connectionState)) {
544
+ console.log("Start button clicked");
545
+ setupWebRTC();
546
+ isRecording = true;
547
+ updateButtonState();
548
+ }
549
+ });
550
+
551
+ updateButtonState();
552
+ </script>
553
+ </body>
554
+
555
+ </html>