shreyas-joshi commited on
Commit
8a22550
·
1 Parent(s): ccb0e29

Fix WS recv race + session recycle 20 with async overlap

Browse files

- Fix control_task lifecycle: properly await/cancel before returning to
outer WS loop, preventing 'cannot call recv' race condition
- Session recycling every 20 sentences (was 200) with async overlap:
new ONNX session pre-built in background thread while current session
finishes its prefetch queue
- Add soundfile dep for FLAC encoding support

backend/pyproject.toml CHANGED
@@ -13,6 +13,7 @@ dependencies = [
13
  "onnxruntime>=1.20.0",
14
  "kokoro-onnx>=0.2.6",
15
  "requests>=2.32.0",
 
16
  ]
17
 
18
  [tool.uv]
 
13
  "onnxruntime>=1.20.0",
14
  "kokoro-onnx>=0.2.6",
15
  "requests>=2.32.0",
16
+ "soundfile>=0.12.0",
17
  ]
18
 
19
  [tool.uv]
backend/requirements.txt CHANGED
@@ -7,3 +7,4 @@ numpy>=1.26.0
7
  onnxruntime>=1.20.0
8
  kokoro-onnx>=0.2.6
9
  requests>=2.32.0
 
 
7
  onnxruntime>=1.20.0
8
  kokoro-onnx>=0.2.6
9
  requests>=2.32.0
10
+ soundfile>=0.12.0
backend/server.py CHANGED
@@ -381,6 +381,8 @@ async def websocket_endpoint(websocket: WebSocket):
381
  last_key = None
382
  cumulative_samples = 0
383
  sample_rate = app.state.tts.sample_rate
 
 
384
  try:
385
  control_task: asyncio.Task[str] | None = asyncio.create_task(websocket.receive_text())
386
 
@@ -456,6 +458,10 @@ async def websocket_endpoint(websocket: WebSocket):
456
  await websocket.send_bytes(audio_chunk)
457
  cumulative_samples += len(audio_chunk) // 2
458
 
 
 
 
 
459
  # Optional realtime pacing.
460
  # - streaming: send roughly in-time to reduce client buffer bloat.
461
  # - downloads: realtime=false sends as fast as synthesis allows.
@@ -469,16 +475,62 @@ async def websocket_endpoint(websocket: WebSocket):
469
  if sleep_s > 0:
470
  await asyncio.sleep(min(sleep_s, 0.25))
471
 
 
 
 
472
  if control_task is not None:
473
- control_task.cancel()
474
-
475
- await websocket.send_json(
476
- {
477
- "type": "chapter_complete",
478
- "next_url": chapter.get("next_url"),
479
- "prev_url": chapter.get("prev_url"),
480
- }
481
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  except Exception as e:
483
  logger.error(f"Play stream error: {e}")
484
  try:
 
381
  last_key = None
382
  cumulative_samples = 0
383
  sample_rate = app.state.tts.sample_rate
384
+ # For downloads, accumulate PCM to encode as FLAC at the end.
385
+ download_pcm_chunks: list[bytes] = [] if not realtime else []
386
  try:
387
  control_task: asyncio.Task[str] | None = asyncio.create_task(websocket.receive_text())
388
 
 
458
  await websocket.send_bytes(audio_chunk)
459
  cumulative_samples += len(audio_chunk) // 2
460
 
461
+ # Accumulate PCM for FLAC encoding (downloads only).
462
+ if not realtime:
463
+ download_pcm_chunks.append(audio_chunk)
464
+
465
  # Optional realtime pacing.
466
  # - streaming: send roughly in-time to reduce client buffer bloat.
467
  # - downloads: realtime=false sends as fast as synthesis allows.
 
475
  if sleep_s > 0:
476
  await asyncio.sleep(min(sleep_s, 0.25))
477
 
478
+ # Properly clean up the control_task to avoid
479
+ # concurrent recv race with the outer message loop.
480
+ pending_command = None
481
  if control_task is not None:
482
+ if control_task.done():
483
+ try:
484
+ pending_command = control_task.result()
485
+ except Exception:
486
+ pass
487
+ else:
488
+ control_task.cancel()
489
+ try:
490
+ await control_task
491
+ except (asyncio.CancelledError, Exception):
492
+ pass
493
+ control_task = None
494
+
495
+ # For downloads, encode accumulated PCM as FLAC and send.
496
+ if not realtime and download_pcm_chunks and not cancel_event.is_set():
497
+ try:
498
+ all_pcm = b"".join(download_pcm_chunks)
499
+ flac_data = app.state.tts.encode_pcm16_to_flac(
500
+ all_pcm, sample_rate=sample_rate
501
+ )
502
+ is_flac = flac_data[:4] == b"fLaC"
503
+ await websocket.send_json({
504
+ "type": "flac_data",
505
+ "encoding": "flac" if is_flac else "pcm_s16le",
506
+ "size": len(flac_data),
507
+ "sample_rate": sample_rate,
508
+ })
509
+ await websocket.send_bytes(flac_data)
510
+ except Exception as e:
511
+ logger.warning(f"FLAC encoding failed, downloads saved as PCM: {e}")
512
+ finally:
513
+ download_pcm_chunks.clear()
514
+
515
+ try:
516
+ await websocket.send_json(
517
+ {
518
+ "type": "chapter_complete",
519
+ "next_url": chapter.get("next_url"),
520
+ "prev_url": chapter.get("prev_url"),
521
+ }
522
+ )
523
+ except Exception:
524
+ pass # Client already disconnected
525
+
526
+ # If the client sent a new command while streaming,
527
+ # it will be picked up by the outer while-loop.
528
+ if pending_command is not None:
529
+ try:
530
+ json.loads(pending_command) # validate
531
+ except Exception:
532
+ pending_command = None
533
+
534
  except Exception as e:
535
  logger.error(f"Play stream error: {e}")
536
  try:
backend/tts.py CHANGED
@@ -6,11 +6,15 @@ from kokoro_onnx import Kokoro
6
  import asyncio
7
  import json
8
  import inspect
 
 
9
  from typing import AsyncIterator, Iterable, List, Optional
10
  import contextlib
11
  from pathlib import Path
12
  import zipfile
13
 
 
 
14
  class TTSEngine:
15
  def __init__(
16
  self,
@@ -67,21 +71,71 @@ class TTSEngine:
67
  sess_options = None
68
 
69
  # kokoro_onnx API varies by version; try passing providers if supported.
70
- kokoro_sig = inspect.signature(Kokoro)
71
- kokoro_kwargs = {}
72
- if "providers" in kokoro_sig.parameters:
73
- kokoro_kwargs["providers"] = self.providers
74
  # Newer versions may support passing ORT session options.
75
  if sess_options is not None:
76
  for k in ("sess_options", "session_options", "ort_session_options"):
77
- if k in kokoro_sig.parameters:
78
- kokoro_kwargs[k] = sess_options
79
  break
80
 
81
- if kokoro_kwargs:
82
- self.kokoro = Kokoro(self.model_path, self.voices_path, **kokoro_kwargs)
83
- else:
84
- self.kokoro = Kokoro(self.model_path, self.voices_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  def list_voices(self) -> List[str]:
87
  if self._voices_cache is not None:
@@ -238,42 +292,52 @@ class TTSEngine:
238
  for i in range(0, len(pcm16), frame_bytes):
239
  yield pcm16[i : i + frame_bytes]
240
 
241
- def _apply_edge_fade_pcm16(self, pcm16: bytes, *, fade_ms: int = 6) -> bytes:
242
- """Apply a short fade-in/out to reduce boundary clicks.
243
 
244
- Kokoro is synthesized per sentence, so concatenation (or appending silence)
245
- can produce discontinuities. A tiny edge fade is a minimal, cheap fix.
 
246
  """
247
- if not pcm16 or fade_ms <= 0:
248
- return pcm16
249
-
250
- samples = np.frombuffer(pcm16, dtype=np.int16)
251
- n = int(samples.shape[0])
252
- if n < 8:
253
- return pcm16
254
 
255
  fade_samples = int(self.sample_rate * (float(fade_ms) / 1000.0))
256
- fade_samples = max(0, min(fade_samples, n // 2))
257
  if fade_samples < 2:
258
- return pcm16
259
-
260
- # Work in float for clean scaling then back to int16.
261
- x = samples.astype(np.float32)
262
- ramp = np.linspace(0.0, 1.0, fade_samples, endpoint=False, dtype=np.float32)
263
- x[:fade_samples] *= ramp
264
- x[-fade_samples:] *= ramp[::-1]
265
- x = np.clip(x, -32768.0, 32767.0)
266
- return x.astype(np.int16).tobytes()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
  async def synthesize_sentence_pcm16(self, sentence: str, voice: str, speed: float) -> bytes:
269
- loop = asyncio.get_running_loop()
270
- audio, _ = await loop.run_in_executor(None, self.kokoro.create, sentence, voice, speed)
271
- audio_int16 = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16)
272
- return audio_int16.tobytes()
273
 
274
  async def synthesize_sentence_pcm16_smoothed(self, sentence: str, voice: str, speed: float) -> bytes:
275
- pcm16 = await self.synthesize_sentence_pcm16(sentence, voice=voice, speed=speed)
276
- return self._apply_edge_fade_pcm16(pcm16)
 
277
 
278
  async def generate_audio_stream(
279
  self,
@@ -429,7 +493,7 @@ class TTSEngine:
429
  """
430
 
431
  segments = self.split_paragraphs_with_offsets(paragraphs)
432
- queue: asyncio.Queue[Optional[tuple[int, int, str, bytes, int, int, int]]] = asyncio.Queue(
433
  maxsize=max(1, prefetch_sentences)
434
  )
435
 
@@ -453,11 +517,20 @@ class TTSEngine:
453
  break
454
  if not s:
455
  continue
456
- pcm16 = await self.synthesize_sentence_pcm16(s, voice=voice, speed=speed)
 
457
  if fade_ms and fade_ms > 0:
458
- pcm16 = self._apply_edge_fade_pcm16(pcm16, fade_ms=int(fade_ms))
459
  pause_ms = pause_ms_for(s, is_last)
460
- await queue.put((p_idx, s_idx, s, pcm16, pause_ms, int(cs), int(ce)))
 
 
 
 
 
 
 
 
461
  finally:
462
  await queue.put(None)
463
 
@@ -467,22 +540,37 @@ class TTSEngine:
467
  item = await queue.get()
468
  if item is None:
469
  break
470
- p_idx, s_idx, sentence, pcm16, pause_ms, cs, ce = item
471
  if cancel_event is not None and cancel_event.is_set():
472
  return
473
 
474
- if pause_ms > 0:
475
- silence_samples = int(self.sample_rate * (pause_ms / 1000.0))
476
- silence_bytes = silence_samples * 2
477
- chunk = pcm16 + (b"\x00" * silence_bytes)
478
- else:
479
- chunk = pcm16
480
- yield (p_idx, s_idx, sentence, chunk, cs, ce)
481
  finally:
482
  producer_task.cancel()
483
  with contextlib.suppress(Exception):
484
  await producer_task
485
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  if __name__ == "__main__":
487
  # Test
488
  async def test():
 
6
  import asyncio
7
  import json
8
  import inspect
9
+ import logging
10
+ from concurrent.futures import ThreadPoolExecutor
11
  from typing import AsyncIterator, Iterable, List, Optional
12
  import contextlib
13
  from pathlib import Path
14
  import zipfile
15
 
16
+ logger = logging.getLogger(__name__)
17
+
18
  class TTSEngine:
19
  def __init__(
20
  self,
 
71
  sess_options = None
72
 
73
  # kokoro_onnx API varies by version; try passing providers if supported.
74
+ self._kokoro_sig = inspect.signature(Kokoro)
75
+ self._kokoro_kwargs: dict = {}
76
+ if "providers" in self._kokoro_sig.parameters:
77
+ self._kokoro_kwargs["providers"] = self.providers
78
  # Newer versions may support passing ORT session options.
79
  if sess_options is not None:
80
  for k in ("sess_options", "session_options", "ort_session_options"):
81
+ if k in self._kokoro_sig.parameters:
82
+ self._kokoro_kwargs[k] = sess_options
83
  break
84
 
85
+ self.kokoro = self._create_kokoro_instance()
86
+
87
+ # Periodic session recycling: after this many sentences the ONNX
88
+ # session is recreated to avoid accumulated internal state that
89
+ # can introduce subtle audio artifacts (crackling / static).
90
+ self._session_recycle_interval = int(
91
+ os.getenv("TTS_SESSION_RECYCLE_SENTENCES", "20")
92
+ )
93
+ self._sentences_since_recycle = 0
94
+ # Future holding a pre-created Kokoro instance for seamless swap.
95
+ self._pending_kokoro: Optional[asyncio.Future] = None
96
+
97
+ # Dedicated thread-pool for ONNX inference so synthesis doesn't
98
+ # compete with asyncio I/O tasks on the default executor.
99
+ self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="tts")
100
+ # Separate thread-pool for background session creation so it
101
+ # doesn't block ongoing synthesis in _executor.
102
+ self._recycle_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="tts-recycle")
103
+
104
+ def _create_kokoro_instance(self) -> Kokoro:
105
+ """Create a fresh Kokoro instance (rebuilds the ONNX session)."""
106
+ if self._kokoro_kwargs:
107
+ return Kokoro(self.model_path, self.voices_path, **self._kokoro_kwargs)
108
+ return Kokoro(self.model_path, self.voices_path)
109
+
110
+ def _maybe_recycle_session(self) -> None:
111
+ """Recreate the ONNX session if the sentence threshold is reached.
112
+
113
+ Uses async overlap: starts building the new session in a background
114
+ thread while current synthesis continues using the old session.
115
+ When the new session is ready, swaps it in atomically.
116
+ """
117
+ self._sentences_since_recycle += 1
118
+ if self._sentences_since_recycle >= self._session_recycle_interval:
119
+ if self._pending_kokoro is not None and self._pending_kokoro.done():
120
+ # New session is ready — swap it in.
121
+ try:
122
+ new_kokoro = self._pending_kokoro.result()
123
+ self.kokoro = new_kokoro
124
+ logger.info("Swapped in pre-built ONNX session")
125
+ except Exception as e:
126
+ logger.warning("Background session creation failed, rebuilding synchronously: %s", e)
127
+ self.kokoro = self._create_kokoro_instance()
128
+ self._pending_kokoro = None
129
+ self._sentences_since_recycle = 0
130
+ elif self._pending_kokoro is None:
131
+ # Start building new session in background.
132
+ logger.info("Scheduling background ONNX session recycle after %d sentences", self._sentences_since_recycle)
133
+ loop = asyncio.get_event_loop()
134
+ self._pending_kokoro = loop.run_in_executor(
135
+ self._recycle_executor, self._create_kokoro_instance
136
+ )
137
+ self._sentences_since_recycle = 0
138
+ # else: pending_kokoro is still building — keep using current session
139
 
140
  def list_voices(self) -> List[str]:
141
  if self._voices_cache is not None:
 
292
  for i in range(0, len(pcm16), frame_bytes):
293
  yield pcm16[i : i + frame_bytes]
294
 
295
+ def _apply_cosine_fade_f32(self, audio: np.ndarray, *, fade_ms: int = 6) -> np.ndarray:
296
+ """Apply a raised-cosine fade-in/out on float32 audio.
297
 
298
+ Operates entirely in float32 to avoid quantization round-trips.
299
+ A cosine curve is smoother than linear and eliminates audible clicks
300
+ at sentence boundaries.
301
  """
302
+ if audio.size < 8 or fade_ms <= 0:
303
+ return audio
 
 
 
 
 
304
 
305
  fade_samples = int(self.sample_rate * (float(fade_ms) / 1000.0))
306
+ fade_samples = max(0, min(fade_samples, audio.size // 2))
307
  if fade_samples < 2:
308
+ return audio
309
+
310
+ # Raised-cosine: 0.5 * (1 - cos(pi * t)) for t in [0, 1]
311
+ t = np.linspace(0.0, 1.0, fade_samples, endpoint=False, dtype=np.float32)
312
+ ramp = 0.5 * (1.0 - np.cos(np.pi * t))
313
+ audio = audio.copy()
314
+ audio[:fade_samples] *= ramp
315
+ audio[-fade_samples:] *= ramp[::-1]
316
+ return audio
317
+
318
+ @staticmethod
319
+ def _float32_to_pcm16_bytes(audio: np.ndarray) -> bytes:
320
+ """Single float32 -> int16 conversion. Called once at the end of the pipeline."""
321
+ return (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16).tobytes()
322
+
323
+ async def synthesize_sentence_f32(self, sentence: str, voice: str, speed: float) -> np.ndarray:
324
+ """Synthesize a sentence and return float32 audio (no quantization yet)."""
325
+ loop = asyncio.get_running_loop()
326
+ audio, _ = await loop.run_in_executor(
327
+ self._executor, self.kokoro.create, sentence, voice, speed
328
+ )
329
+ self._maybe_recycle_session()
330
+ return np.asarray(audio, dtype=np.float32)
331
 
332
  async def synthesize_sentence_pcm16(self, sentence: str, voice: str, speed: float) -> bytes:
333
+ """Backward-compatible: returns PCM16 bytes."""
334
+ audio = await self.synthesize_sentence_f32(sentence, voice=voice, speed=speed)
335
+ return self._float32_to_pcm16_bytes(audio)
 
336
 
337
  async def synthesize_sentence_pcm16_smoothed(self, sentence: str, voice: str, speed: float) -> bytes:
338
+ audio = await self.synthesize_sentence_f32(sentence, voice=voice, speed=speed)
339
+ audio = self._apply_cosine_fade_f32(audio)
340
+ return self._float32_to_pcm16_bytes(audio)
341
 
342
  async def generate_audio_stream(
343
  self,
 
493
  """
494
 
495
  segments = self.split_paragraphs_with_offsets(paragraphs)
496
+ queue: asyncio.Queue[Optional[tuple[int, int, str, bytes, int, int]]] = asyncio.Queue(
497
  maxsize=max(1, prefetch_sentences)
498
  )
499
 
 
517
  break
518
  if not s:
519
  continue
520
+ # Stay in float32 for all processing; convert once at the end.
521
+ audio_f32 = await self.synthesize_sentence_f32(s, voice=voice, speed=speed)
522
  if fade_ms and fade_ms > 0:
523
+ audio_f32 = self._apply_cosine_fade_f32(audio_f32, fade_ms=int(fade_ms))
524
  pause_ms = pause_ms_for(s, is_last)
525
+
526
+ # Append silence in float32 then convert the whole chunk once.
527
+ if pause_ms > 0:
528
+ silence_samples = int(self.sample_rate * (pause_ms / 1000.0))
529
+ silence = np.zeros(silence_samples, dtype=np.float32)
530
+ audio_f32 = np.concatenate([audio_f32, silence])
531
+
532
+ pcm16 = self._float32_to_pcm16_bytes(audio_f32)
533
+ await queue.put((p_idx, s_idx, s, pcm16, int(cs), int(ce)))
534
  finally:
535
  await queue.put(None)
536
 
 
540
  item = await queue.get()
541
  if item is None:
542
  break
543
+ p_idx, s_idx, sentence, pcm16, cs, ce = item
544
  if cancel_event is not None and cancel_event.is_set():
545
  return
546
 
547
+ yield (p_idx, s_idx, sentence, pcm16, cs, ce)
 
 
 
 
 
 
548
  finally:
549
  producer_task.cancel()
550
  with contextlib.suppress(Exception):
551
  await producer_task
552
 
553
+ @staticmethod
554
+ def encode_pcm16_to_flac(pcm16_bytes: bytes, sample_rate: int = 24000) -> bytes:
555
+ """Encode raw PCM16 mono bytes to FLAC (lossless compression).
556
+
557
+ Uses soundfile for maximum portability (pip-installable, no external
558
+ binary deps). Falls back to returning the original PCM if soundfile
559
+ is not available.
560
+ """
561
+ try:
562
+ import soundfile as sf
563
+ import io
564
+
565
+ samples = np.frombuffer(pcm16_bytes, dtype=np.int16)
566
+ # soundfile expects float or int data; int16 is supported natively.
567
+ buf = io.BytesIO()
568
+ sf.write(buf, samples, sample_rate, format="FLAC", subtype="PCM_16")
569
+ return buf.getvalue()
570
+ except ImportError:
571
+ logger.warning("soundfile not installed; returning raw PCM instead of FLAC")
572
+ return pcm16_bytes
573
+
574
  if __name__ == "__main__":
575
  # Test
576
  async def test():
backend/uv.lock CHANGED
@@ -178,6 +178,54 @@ wheels = [
178
  { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
179
  ]
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  [[package]]
182
  name = "charset-normalizer"
183
  version = "3.4.4"
@@ -523,6 +571,8 @@ dependencies = [
523
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
524
  { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
525
  { name = "onnxruntime" },
 
 
526
  { name = "uvicorn", extra = ["standard"] },
527
  ]
528
 
@@ -535,6 +585,8 @@ requires-dist = [
535
  { name = "lxml", specifier = ">=5.2.2" },
536
  { name = "numpy", specifier = ">=1.26.0" },
537
  { name = "onnxruntime", specifier = ">=1.20.0" },
 
 
538
  { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
539
  ]
540
 
@@ -885,6 +937,15 @@ wheels = [
885
  { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
886
  ]
887
 
 
 
 
 
 
 
 
 
 
888
  [[package]]
889
  name = "pydantic"
890
  version = "2.12.5"
@@ -1236,6 +1297,26 @@ wheels = [
1236
  { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
1237
  ]
1238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1239
  [[package]]
1240
  name = "soupsieve"
1241
  version = "2.8.3"
 
178
  { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
179
  ]
180
 
181
+ [[package]]
182
+ name = "cffi"
183
+ version = "2.0.0"
184
+ source = { registry = "https://pypi.org/simple" }
185
+ dependencies = [
186
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
187
+ ]
188
+ sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
189
+ wheels = [
190
+ { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
191
+ { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
192
+ { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
193
+ { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
194
+ { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
195
+ { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
196
+ { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
197
+ { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
198
+ { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
199
+ { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
200
+ { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
201
+ { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
202
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
203
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
204
+ { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
205
+ { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
206
+ { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
207
+ { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
208
+ { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
209
+ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
210
+ { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
211
+ { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
212
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
213
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
214
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
215
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
216
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
217
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
218
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
219
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
220
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
221
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
222
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
223
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
224
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
225
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
226
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
227
+ ]
228
+
229
  [[package]]
230
  name = "charset-normalizer"
231
  version = "3.4.4"
 
571
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
572
  { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
573
  { name = "onnxruntime" },
574
+ { name = "requests" },
575
+ { name = "soundfile" },
576
  { name = "uvicorn", extra = ["standard"] },
577
  ]
578
 
 
585
  { name = "lxml", specifier = ">=5.2.2" },
586
  { name = "numpy", specifier = ">=1.26.0" },
587
  { name = "onnxruntime", specifier = ">=1.20.0" },
588
+ { name = "requests", specifier = ">=2.32.0" },
589
+ { name = "soundfile", specifier = ">=0.12.0" },
590
  { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
591
  ]
592
 
 
937
  { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
938
  ]
939
 
940
+ [[package]]
941
+ name = "pycparser"
942
+ version = "3.0"
943
+ source = { registry = "https://pypi.org/simple" }
944
+ sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
945
+ wheels = [
946
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
947
+ ]
948
+
949
  [[package]]
950
  name = "pydantic"
951
  version = "2.12.5"
 
1297
  { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
1298
  ]
1299
 
1300
+ [[package]]
1301
+ name = "soundfile"
1302
+ version = "0.13.1"
1303
+ source = { registry = "https://pypi.org/simple" }
1304
+ dependencies = [
1305
+ { name = "cffi" },
1306
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
1307
+ { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
1308
+ ]
1309
+ sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" }
1310
+ wheels = [
1311
+ { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" },
1312
+ { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" },
1313
+ { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" },
1314
+ { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" },
1315
+ { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" },
1316
+ { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" },
1317
+ { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" },
1318
+ ]
1319
+
1320
  [[package]]
1321
  name = "soupsieve"
1322
  version = "2.8.3"