freddyaboulton HF Staff commited on
Commit
24ba950
·
verified ·
1 Parent(s): b37e5cb

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. README_gradio.md +1 -1
  2. app.py +3 -6
  3. index.html +219 -56
  4. requirements.txt +1 -1
README_gradio.md CHANGED
@@ -9,7 +9,7 @@ app_file: app.py
9
  pinned: false
10
  license: mit
11
  short_description: Talk to Gemini (Gradio UI)
12
- tags: [webrtc, websocket, gradio, secret|TWILIO_ACCOUNT_SID, secret|TWILIO_AUTH_TOKEN, secret|GEMINI_API_KEY]
13
  ---
14
 
15
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
9
  pinned: false
10
  license: mit
11
  short_description: Talk to Gemini (Gradio UI)
12
+ tags: [webrtc, websocket, gradio, secret|HF_TOKEN, secret|GEMINI_API_KEY]
13
  ---
14
 
15
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py CHANGED
@@ -13,7 +13,7 @@ from fastapi.responses import HTMLResponse
13
  from fastrtc import (
14
  AsyncStreamHandler,
15
  Stream,
16
- get_twilio_turn_credentials,
17
  wait_for_item,
18
  )
19
  from google import genai
@@ -43,12 +43,10 @@ class GeminiHandler(AsyncStreamHandler):
43
  self,
44
  expected_layout: Literal["mono"] = "mono",
45
  output_sample_rate: int = 24000,
46
- output_frame_size: int = 480,
47
  ) -> None:
48
  super().__init__(
49
  expected_layout,
50
  output_sample_rate,
51
- output_frame_size,
52
  input_sample_rate=16000,
53
  )
54
  self.input_queue: asyncio.Queue = asyncio.Queue()
@@ -59,7 +57,6 @@ class GeminiHandler(AsyncStreamHandler):
59
  return GeminiHandler(
60
  expected_layout="mono",
61
  output_sample_rate=self.output_sample_rate,
62
- output_frame_size=self.output_frame_size,
63
  )
64
 
65
  async def start_up(self):
@@ -119,7 +116,7 @@ stream = Stream(
119
  modality="audio",
120
  mode="send-receive",
121
  handler=GeminiHandler(),
122
- rtc_configuration=get_twilio_turn_credentials() if get_space() else None,
123
  concurrency_limit=5 if get_space() else None,
124
  time_limit=90 if get_space() else None,
125
  additional_inputs=[
@@ -162,7 +159,7 @@ async def _(body: InputData):
162
 
163
  @app.get("/")
164
  async def index():
165
- rtc_config = get_twilio_turn_credentials() if get_space() else None
166
  html_content = (current_dir / "index.html").read_text()
167
  html_content = html_content.replace("__RTC_CONFIGURATION__", json.dumps(rtc_config))
168
  return HTMLResponse(content=html_content)
 
13
  from fastrtc import (
14
  AsyncStreamHandler,
15
  Stream,
16
+ get_cloudflare_turn_credentials_async,
17
  wait_for_item,
18
  )
19
  from google import genai
 
43
  self,
44
  expected_layout: Literal["mono"] = "mono",
45
  output_sample_rate: int = 24000,
 
46
  ) -> None:
47
  super().__init__(
48
  expected_layout,
49
  output_sample_rate,
 
50
  input_sample_rate=16000,
51
  )
52
  self.input_queue: asyncio.Queue = asyncio.Queue()
 
57
  return GeminiHandler(
58
  expected_layout="mono",
59
  output_sample_rate=self.output_sample_rate,
 
60
  )
61
 
62
  async def start_up(self):
 
116
  modality="audio",
117
  mode="send-receive",
118
  handler=GeminiHandler(),
119
+ rtc_configuration=get_cloudflare_turn_credentials_async if get_space() else None,
120
  concurrency_limit=5 if get_space() else None,
121
  time_limit=90 if get_space() else None,
122
  additional_inputs=[
 
159
 
160
  @app.get("/")
161
  async def index():
162
+ rtc_config = await get_cloudflare_turn_credentials_async() if get_space() else None
163
  html_content = (current_dir / "index.html").read_text()
164
  html_content = html_content.replace("__RTC_CONFIGURATION__", json.dumps(rtc_config))
165
  return HTMLResponse(content=html_content)
index.html CHANGED
@@ -98,6 +98,11 @@
98
  font-weight: 600;
99
  cursor: pointer;
100
  transition: all 0.2s ease;
 
 
 
 
 
101
  }
102
 
103
  button:hover {
@@ -134,7 +139,6 @@
134
  align-items: center;
135
  justify-content: center;
136
  gap: 12px;
137
- min-width: 180px;
138
  }
139
 
140
  .pulse-circle {
@@ -171,6 +175,23 @@
171
  background-color: #ffd700;
172
  color: black;
173
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  </style>
175
  </head>
176
 
@@ -221,6 +242,11 @@
221
  let dataChannel;
222
  let isRecording = false;
223
  let webrtc_id;
 
 
 
 
 
224
 
225
  const startButton = document.getElementById('start-button');
226
  const apiKeyInput = document.getElementById('api-key');
@@ -235,7 +261,28 @@
235
  boxContainer.appendChild(box);
236
  }
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  function updateButtonState() {
 
 
 
239
  if (peerConnection && (peerConnection.connectionState === 'connecting' || peerConnection.connectionState === 'new')) {
240
  startButton.innerHTML = `
241
  <div class="icon-with-spinner">
@@ -243,15 +290,28 @@
243
  <span>Connecting...</span>
244
  </div>
245
  `;
 
246
  } else if (peerConnection && peerConnection.connectionState === 'connected') {
247
- startButton.innerHTML = `
248
- <div class="pulse-container">
249
- <div class="pulse-circle"></div>
250
- <span>Stop Recording</span>
251
- </div>
252
  `;
 
 
 
 
 
 
 
 
 
 
 
253
  } else {
254
  startButton.innerHTML = 'Start Recording';
 
255
  }
256
  }
257
 
@@ -267,6 +327,23 @@
267
  }, 5000);
268
  }
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  async function setupWebRTC() {
271
  const config = __RTC_CONFIGURATION__;
272
  peerConnection = new RTCPeerConnection(config);
@@ -288,58 +365,59 @@
288
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
289
  stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
290
 
291
- // Update audio visualization setup
292
- audioContext = new AudioContext();
 
 
 
 
 
 
293
  analyser_input = audioContext.createAnalyser();
294
- const source = audioContext.createMediaStreamSource(stream);
295
- source.connect(analyser_input);
296
  analyser_input.fftSize = 64;
297
  dataArray_input = new Uint8Array(analyser_input.frequencyBinCount);
298
-
299
- function updateAudioLevel() {
300
- analyser_input.getByteFrequencyData(dataArray_input);
301
- const average = Array.from(dataArray_input).reduce((a, b) => a + b, 0) / dataArray_input.length;
302
- const audioLevel = average / 255;
303
-
304
- const pulseCircle = document.querySelector('.pulse-circle');
305
- if (pulseCircle) {
306
- console.log("audioLevel", audioLevel);
307
- pulseCircle.style.setProperty('--audio-level', 1 + audioLevel);
308
- }
309
-
310
- animationId = requestAnimationFrame(updateAudioLevel);
311
- }
312
  updateAudioLevel();
313
 
314
- // Add connection state change listener
315
  peerConnection.addEventListener('connectionstatechange', () => {
316
  console.log('connectionstatechange', peerConnection.connectionState);
317
  if (peerConnection.connectionState === 'connected') {
318
  clearTimeout(timeoutId);
319
  const toast = document.getElementById('error-toast');
320
  toast.style.display = 'none';
 
 
 
 
 
321
  }
322
  updateButtonState();
323
  });
324
 
325
- // Handle incoming audio
326
  peerConnection.addEventListener('track', (evt) => {
327
- if (audioOutput && audioOutput.srcObject !== evt.streams[0]) {
328
- audioOutput.srcObject = evt.streams[0];
329
- audioOutput.play();
330
-
331
- // Set up audio visualization on the output stream
332
- audioContext = new AudioContext();
333
- analyser = audioContext.createAnalyser();
334
- const source = audioContext.createMediaStreamSource(evt.streams[0]);
335
- source.connect(analyser);
336
- analyser.fftSize = 2048;
337
- dataArray = new Uint8Array(analyser.frequencyBinCount);
338
- updateVisualization();
 
 
 
 
 
 
 
 
339
  }
340
  });
341
 
342
- // Create data channel for messages
343
  dataChannel = peerConnection.createDataChannel('text');
344
  dataChannel.onmessage = (event) => {
345
  const eventJson = JSON.parse(event.data);
@@ -360,7 +438,6 @@
360
  }
361
  };
362
 
363
- // Create and send offer
364
  const offer = await peerConnection.createOffer();
365
  await peerConnection.setLocalDescription(offer);
366
 
@@ -394,7 +471,7 @@
394
  showError(serverResponse.meta.error === 'concurrency_limit_reached'
395
  ? `Too many connections. Maximum limit is ${serverResponse.meta.limit}`
396
  : serverResponse.meta.error);
397
- stop();
398
  startButton.textContent = 'Start Recording';
399
  return;
400
  }
@@ -404,13 +481,17 @@
404
  clearTimeout(timeoutId);
405
  console.error('Error setting up WebRTC:', err);
406
  showError('Failed to establish connection. Please try again.');
407
- stop();
408
  startButton.textContent = 'Start Recording';
409
  }
410
  }
411
 
412
  function updateVisualization() {
413
- if (!analyser) return;
 
 
 
 
414
 
415
  analyser.getByteFrequencyData(dataArray);
416
  const bars = document.querySelectorAll('.box');
@@ -420,32 +501,114 @@
420
  bars[i].style.transform = `scaleY(${Math.max(0.1, barHeight)})`;
421
  }
422
 
423
- animationId = requestAnimationFrame(updateVisualization);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  }
425
 
426
  function stopWebRTC() {
 
427
  if (peerConnection) {
428
- peerConnection.close();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  }
430
- if (animationId) {
431
- cancelAnimationFrame(animationId);
 
432
  }
433
- if (audioContext) {
434
- audioContext.close();
 
 
 
 
 
 
 
 
 
435
  }
 
 
 
 
 
 
 
 
436
  updateButtonState();
 
 
 
 
 
 
 
437
  }
438
 
439
- startButton.addEventListener('click', () => {
440
- if (!isRecording) {
441
- setupWebRTC();
442
- startButton.classList.add('recording');
443
- } else {
 
 
444
  stopWebRTC();
445
- startButton.classList.remove('recording');
 
 
 
 
 
 
 
 
446
  }
447
- isRecording = !isRecording;
448
  });
 
 
449
  </script>
450
  </body>
451
 
 
98
  font-weight: 600;
99
  cursor: pointer;
100
  transition: all 0.2s ease;
101
+ display: flex;
102
+ align-items: center;
103
+ justify-content: center;
104
+ gap: 12px;
105
+ min-width: 180px;
106
  }
107
 
108
  button:hover {
 
139
  align-items: center;
140
  justify-content: center;
141
  gap: 12px;
 
142
  }
143
 
144
  .pulse-circle {
 
175
  background-color: #ffd700;
176
  color: black;
177
  }
178
+
179
+ /* Add styles for the mute toggle */
180
+ .mute-toggle {
181
+ width: 24px;
182
+ height: 24px;
183
+ cursor: pointer;
184
+ flex-shrink: 0;
185
+ }
186
+
187
+ .mute-toggle svg {
188
+ display: block;
189
+ }
190
+
191
+ #start-button {
192
+ margin-left: auto;
193
+ margin-right: auto;
194
+ }
195
  </style>
196
  </head>
197
 
 
242
  let dataChannel;
243
  let isRecording = false;
244
  let webrtc_id;
245
+ let isMuted = false;
246
+ let analyser_input, dataArray_input;
247
+ let analyser, dataArray;
248
+ let source_input = null;
249
+ let source_output = null;
250
 
251
  const startButton = document.getElementById('start-button');
252
  const apiKeyInput = document.getElementById('api-key');
 
261
  boxContainer.appendChild(box);
262
  }
263
 
264
+ // SVG Icons
265
+ const micIconSVG = `
266
+ <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">
267
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
268
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
269
+ <line x1="12" y1="19" x2="12" y2="23"></line>
270
+ <line x1="8" y1="23" x2="16" y2="23"></line>
271
+ </svg>`;
272
+
273
+ const micMutedIconSVG = `
274
+ <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">
275
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
276
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
277
+ <line x1="12" y1="19" x2="12" y2="23"></line>
278
+ <line x1="8" y1="23" x2="16" y2="23"></line>
279
+ <line x1="1" y1="1" x2="23" y2="23"></line>
280
+ </svg>`;
281
+
282
  function updateButtonState() {
283
+ startButton.innerHTML = '';
284
+ startButton.onclick = null;
285
+
286
  if (peerConnection && (peerConnection.connectionState === 'connecting' || peerConnection.connectionState === 'new')) {
287
  startButton.innerHTML = `
288
  <div class="icon-with-spinner">
 
290
  <span>Connecting...</span>
291
  </div>
292
  `;
293
+ startButton.disabled = true;
294
  } else if (peerConnection && peerConnection.connectionState === 'connected') {
295
+ const pulseContainer = document.createElement('div');
296
+ pulseContainer.className = 'pulse-container';
297
+ pulseContainer.innerHTML = `
298
+ <div class="pulse-circle"></div>
299
+ <span>Stop Recording</span>
300
  `;
301
+
302
+ const muteToggle = document.createElement('div');
303
+ muteToggle.className = 'mute-toggle';
304
+ muteToggle.title = isMuted ? 'Unmute' : 'Mute';
305
+ muteToggle.innerHTML = isMuted ? micMutedIconSVG : micIconSVG;
306
+ muteToggle.addEventListener('click', toggleMute);
307
+
308
+ startButton.appendChild(pulseContainer);
309
+ startButton.appendChild(muteToggle);
310
+ startButton.disabled = false;
311
+
312
  } else {
313
  startButton.innerHTML = 'Start Recording';
314
+ startButton.disabled = false;
315
  }
316
  }
317
 
 
327
  }, 5000);
328
  }
329
 
330
+ function toggleMute(event) {
331
+ event.stopPropagation();
332
+ if (!peerConnection || peerConnection.connectionState !== 'connected') return;
333
+
334
+ isMuted = !isMuted;
335
+ console.log("Mute toggled:", isMuted);
336
+
337
+ peerConnection.getSenders().forEach(sender => {
338
+ if (sender.track && sender.track.kind === 'audio') {
339
+ sender.track.enabled = !isMuted;
340
+ console.log(`Audio track ${sender.track.id} enabled: ${!isMuted}`);
341
+ }
342
+ });
343
+
344
+ updateButtonState();
345
+ }
346
+
347
  async function setupWebRTC() {
348
  const config = __RTC_CONFIGURATION__;
349
  peerConnection = new RTCPeerConnection(config);
 
365
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
366
  stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
367
 
368
+ if (!audioContext || audioContext.state === 'closed') {
369
+ audioContext = new AudioContext();
370
+ }
371
+ if (source_input) {
372
+ try { source_input.disconnect(); } catch (e) { console.warn("Error disconnecting previous input source:", e); }
373
+ source_input = null;
374
+ }
375
+ source_input = audioContext.createMediaStreamSource(stream);
376
  analyser_input = audioContext.createAnalyser();
377
+ source_input.connect(analyser_input);
 
378
  analyser_input.fftSize = 64;
379
  dataArray_input = new Uint8Array(analyser_input.frequencyBinCount);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  updateAudioLevel();
381
 
 
382
  peerConnection.addEventListener('connectionstatechange', () => {
383
  console.log('connectionstatechange', peerConnection.connectionState);
384
  if (peerConnection.connectionState === 'connected') {
385
  clearTimeout(timeoutId);
386
  const toast = document.getElementById('error-toast');
387
  toast.style.display = 'none';
388
+ if (analyser_input) updateAudioLevel();
389
+ if (analyser) updateVisualization();
390
+ } else if (['disconnected', 'failed', 'closed'].includes(peerConnection.connectionState)) {
391
+ // Explicitly stop animations if connection drops unexpectedly
392
+ // Note: stopWebRTC() handles the normal stop case
393
  }
394
  updateButtonState();
395
  });
396
 
 
397
  peerConnection.addEventListener('track', (evt) => {
398
+ if (evt.track.kind === 'audio' && audioOutput) {
399
+ if (audioOutput.srcObject !== evt.streams[0]) {
400
+ audioOutput.srcObject = evt.streams[0];
401
+ audioOutput.play().catch(e => console.error("Audio play failed:", e));
402
+
403
+ if (!audioContext || audioContext.state === 'closed') {
404
+ console.warn("AudioContext not ready for output track analysis.");
405
+ return;
406
+ }
407
+ if (source_output) {
408
+ try { source_output.disconnect(); } catch (e) { console.warn("Error disconnecting previous output source:", e); }
409
+ source_output = null;
410
+ }
411
+ source_output = audioContext.createMediaStreamSource(evt.streams[0]);
412
+ analyser = audioContext.createAnalyser();
413
+ source_output.connect(analyser);
414
+ analyser.fftSize = 2048;
415
+ dataArray = new Uint8Array(analyser.frequencyBinCount);
416
+ updateVisualization();
417
+ }
418
  }
419
  });
420
 
 
421
  dataChannel = peerConnection.createDataChannel('text');
422
  dataChannel.onmessage = (event) => {
423
  const eventJson = JSON.parse(event.data);
 
438
  }
439
  };
440
 
 
441
  const offer = await peerConnection.createOffer();
442
  await peerConnection.setLocalDescription(offer);
443
 
 
471
  showError(serverResponse.meta.error === 'concurrency_limit_reached'
472
  ? `Too many connections. Maximum limit is ${serverResponse.meta.limit}`
473
  : serverResponse.meta.error);
474
+ stopWebRTC();
475
  startButton.textContent = 'Start Recording';
476
  return;
477
  }
 
481
  clearTimeout(timeoutId);
482
  console.error('Error setting up WebRTC:', err);
483
  showError('Failed to establish connection. Please try again.');
484
+ stopWebRTC();
485
  startButton.textContent = 'Start Recording';
486
  }
487
  }
488
 
489
  function updateVisualization() {
490
+ if (!analyser || !peerConnection || !['connected', 'connecting'].includes(peerConnection.connectionState)) {
491
+ const bars = document.querySelectorAll('.box');
492
+ bars.forEach(bar => bar.style.transform = 'scaleY(0.1)');
493
+ return;
494
+ }
495
 
496
  analyser.getByteFrequencyData(dataArray);
497
  const bars = document.querySelectorAll('.box');
 
501
  bars[i].style.transform = `scaleY(${Math.max(0.1, barHeight)})`;
502
  }
503
 
504
+ requestAnimationFrame(updateVisualization);
505
+ }
506
+
507
+ function updateAudioLevel() {
508
+ if (!analyser_input || !peerConnection || !['connected', 'connecting'].includes(peerConnection.connectionState)) {
509
+ const pulseCircle = document.querySelector('.pulse-circle');
510
+ if (pulseCircle) {
511
+ pulseCircle.style.setProperty('--audio-level', 1);
512
+ }
513
+ return;
514
+ }
515
+ analyser_input.getByteFrequencyData(dataArray_input);
516
+ const average = Array.from(dataArray_input).reduce((a, b) => a + b, 0) / dataArray_input.length;
517
+ const audioLevel = average / 255;
518
+
519
+ const pulseCircle = document.querySelector('.pulse-circle');
520
+ if (pulseCircle) {
521
+ pulseCircle.style.setProperty('--audio-level', 1 + audioLevel);
522
+ }
523
+
524
+ requestAnimationFrame(updateAudioLevel);
525
  }
526
 
527
  function stopWebRTC() {
528
+ console.log("Running stopWebRTC");
529
  if (peerConnection) {
530
+ peerConnection.getSenders().forEach(sender => {
531
+ if (sender.track) {
532
+ sender.track.stop();
533
+ }
534
+ });
535
+ peerConnection.ontrack = null;
536
+ peerConnection.onicegatheringstatechange = null;
537
+ peerConnection.onconnectionstatechange = null;
538
+
539
+ if (dataChannel) {
540
+ dataChannel.onmessage = null;
541
+ try { dataChannel.close(); } catch (e) { console.warn("Error closing data channel:", e); }
542
+ dataChannel = null;
543
+ }
544
+ try { peerConnection.close(); } catch (e) { console.warn("Error closing peer connection:", e); }
545
+ peerConnection = null;
546
+ }
547
+
548
+ if (audioOutput) {
549
+ audioOutput.pause();
550
+ audioOutput.srcObject = null;
551
+ }
552
+
553
+ if (source_input) {
554
+ try { source_input.disconnect(); } catch (e) { console.warn("Error disconnecting input source:", e); }
555
+ source_input = null;
556
  }
557
+ if (source_output) {
558
+ try { source_output.disconnect(); } catch (e) { console.warn("Error disconnecting output source:", e); }
559
+ source_output = null;
560
  }
561
+
562
+ if (audioContext && audioContext.state !== 'closed') {
563
+ audioContext.close().then(() => {
564
+ console.log("AudioContext closed successfully.");
565
+ audioContext = null;
566
+ }).catch(e => {
567
+ console.error("Error closing AudioContext:", e);
568
+ audioContext = null;
569
+ });
570
+ } else {
571
+ audioContext = null;
572
  }
573
+
574
+ analyser_input = null;
575
+ dataArray_input = null;
576
+ analyser = null;
577
+ dataArray = null;
578
+
579
+ isMuted = false;
580
+ isRecording = false;
581
  updateButtonState();
582
+
583
+ const bars = document.querySelectorAll('.box');
584
+ bars.forEach(bar => bar.style.transform = 'scaleY(0.1)');
585
+ const pulseCircle = document.querySelector('.pulse-circle');
586
+ if (pulseCircle) {
587
+ pulseCircle.style.setProperty('--audio-level', 1);
588
+ }
589
  }
590
 
591
+ startButton.addEventListener('click', (event) => {
592
+ if (event.target.closest('.mute-toggle')) {
593
+ return;
594
+ }
595
+
596
+ if (peerConnection && peerConnection.connectionState === 'connected') {
597
+ console.log("Stop button clicked");
598
  stopWebRTC();
599
+ } else if (!peerConnection || ['new', 'closed', 'failed', 'disconnected'].includes(peerConnection.connectionState)) {
600
+ console.log("Start button clicked");
601
+ if (!apiKeyInput.value) {
602
+ showError("Please enter your API Key.");
603
+ return;
604
+ }
605
+ setupWebRTC();
606
+ isRecording = true;
607
+ updateButtonState();
608
  }
 
609
  });
610
+
611
+ updateButtonState();
612
  </script>
613
  </body>
614
 
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- fastrtc
2
  python-dotenv
3
  google-genai
4
  twilio
 
1
+ fastrtc[vad]==0.0.20.rc2
2
  python-dotenv
3
  google-genai
4
  twilio