3v324v23 commited on
Commit
cd36c5d
·
1 Parent(s): 075de80

feat(tts): enhance TTS request handling with concurrency limits and pacing for improved performance

Browse files
Files changed (3) hide show
  1. app/main.py +92 -54
  2. app/tts.py +41 -13
  3. tests/test_scoring.py +43 -5
app/main.py CHANGED
@@ -85,6 +85,7 @@ def subject_code(label: object) -> str:
85
  return next((code for code, name in SUBJECT_LABELS.items() if name == label), "")
86
  MODEL_ID = os.environ.get("MODEL_ID", "facebook/wav2vec2-xlsr-53-espeak-cv-ft")
87
  LOCAL_TZ = ZoneInfo("Asia/Jerusalem") # a day of practice is the students' calendar day
 
88
  MAX_UPLOAD = 2_000_000 # bytes; 15 s of opus is ~100 KB
89
  MAX_BACKUP_UPLOAD = 250_000_000
90
  EMAIL = re.compile(r"[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+")
@@ -2665,23 +2666,27 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2665
  remote: dict[str, int] = {}
2666
  pending: list[tuple[Path | bytes, str, int]] = []
2667
  oldest = [0.0] # when the oldest pending file was queued: a batch waits at most 30 s for its 25th file
 
2668
 
2669
  def flush() -> None:
2670
- if not pending:
 
 
 
2671
  return
2672
  try:
2673
- mirror.upload([(src, dest) for src, dest, _size in pending])
2674
- remote.update({dest: size for _src, dest, size in pending})
 
2675
  with app.state.tts_warm_lock:
2676
- app.state.tts_warm["uploaded"] += len(pending)
2677
  except Exception as e:
2678
- log.warning("tts bucket upload of %d files failed: %s", len(pending), e)
2679
  with app.state.tts_warm_lock:
2680
- app.state.tts_warm["upload_failed"] += len(pending)
2681
  app.state.tts_warm["upload_error"] = str(e)[:300]
2682
- pending.clear()
2683
 
2684
- def queue(src: Path | bytes, rel: str) -> None:
2685
  if mirror is None:
2686
  return
2687
  try:
@@ -2689,14 +2694,48 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2689
  except OSError:
2690
  return
2691
  dest = mirror.remote_path(rel)
2692
- if remote.get(dest) == size or any(d == dest for _s, d, _z in pending):
2693
- return
2694
- if not pending:
2695
- oldest[0] = time.monotonic()
2696
- pending.append((src, dest, size))
2697
- if len(pending) >= 25 or time.monotonic() - oldest[0] > 30:
 
 
2698
  flush()
2699
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2700
  def run() -> None:
2701
  if mirror is not None:
2702
  try:
@@ -2705,57 +2744,56 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2705
  log.warning("tts bucket listing failed: %s", e)
2706
  with app.state.tts_warm_lock:
2707
  app.state.tts_warm["upload_error"] = f"listing failed: {str(e)[:300]}"
 
 
 
2708
  for text in jobs:
2709
  if stop.is_set():
2710
  break
2711
- with app.state.tts_warm_lock:
2712
- app.state.tts_warm["current"] = text
2713
  try:
2714
- # Already cached: skip without reading it. The cache can sit on a bucket mount, where reading
2715
- # each WAV is a network fetch; the upload pass at the end still sends it if the bucket lacks it.
2716
- if tts.cached(text, voice):
2717
- with app.state.tts_warm_lock:
2718
- app.state.tts_warm["skipped"] += 1
2719
- app.state.tts_warm["done"] += 1
2720
- continue
2721
- was_cached = interrupted = False
2722
- for attempt in range(5):
2723
- try:
2724
- data, _used = tts.wav_for_voice(text, voice) # serialised: one request in this replica
2725
- if mirror is not None: # the disk copy if it was kept, else the bytes themselves
2726
- queue(tts.cache_file(text, voice) or data, tts.cache_key(text, voice))
2727
- break
2728
- except TTSUnavailable as e: # rate limit, provider 5xx or a dropped connection: retry
2729
- if not e.transient or attempt == 4:
2730
- raise
2731
- delay = max(.2, min(e.retry_after or 2 ** (attempt + 1), 60))
2732
- with app.state.tts_warm_lock:
2733
- app.state.tts_warm["waiting"] = round(delay, 1)
2734
- interrupted = stop.wait(delay) # a stop request ends the wait at once
2735
- with app.state.tts_warm_lock:
2736
- app.state.tts_warm["waiting"] = 0
2737
- if interrupted:
2738
- break
2739
- if interrupted:
2740
- break
2741
  with app.state.tts_warm_lock:
2742
- app.state.tts_warm["skipped" if was_cached else "generated"] += 1
2743
- app.state.tts_warm["done"] += 1
2744
- except Exception as e:
2745
- log.warning("tts cache warm stopped at %r: %s", text, e)
2746
- with app.state.tts_warm_lock:
2747
- app.state.tts_warm["failed"] += 1
2748
- app.state.tts_warm["error"] = str(e)
2749
- break
2750
- if mirror is not None and stop.is_set():
2751
- flush() # what was generated before the stop still reaches the bucket; the full pass waits for next run
 
 
 
 
 
 
 
 
 
2752
  elif mirror is not None:
2753
  # also everything cached earlier: other voices, playback misses, the legacy flat layout
2754
  with app.state.tts_warm_lock:
2755
  app.state.tts_warm["current"] = "uploading cached files"
2756
  try:
2757
  for path, rel in tts.cached_files():
2758
- queue(path, rel)
2759
  except OSError as e:
2760
  log.warning("tts cache listing failed: %s", e)
2761
  flush()
 
85
  return next((code for code, name in SUBJECT_LABELS.items() if name == label), "")
86
  MODEL_ID = os.environ.get("MODEL_ID", "facebook/wav2vec2-xlsr-53-espeak-cv-ft")
87
  LOCAL_TZ = ZoneInfo("Asia/Jerusalem") # a day of practice is the students' calendar day
88
+ WARM_WORKERS = 3 # Sawtak requests the cache-warm sweep runs at once (tts.MAX_CONCURRENT and its pace apply)
89
  MAX_UPLOAD = 2_000_000 # bytes; 15 s of opus is ~100 KB
90
  MAX_BACKUP_UPLOAD = 250_000_000
91
  EMAIL = re.compile(r"[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+")
 
2666
  remote: dict[str, int] = {}
2667
  pending: list[tuple[Path | bytes, str, int]] = []
2668
  oldest = [0.0] # when the oldest pending file was queued: a batch waits at most 30 s for its 25th file
2669
+ uploads = threading.Lock() # guards remote, pending and oldest: the workers queue at the same time
2670
 
2671
  def flush() -> None:
2672
+ with uploads:
2673
+ batch = pending[:]
2674
+ pending.clear()
2675
+ if not batch:
2676
  return
2677
  try:
2678
+ mirror.upload([(src, dest) for src, dest, _size in batch])
2679
+ with uploads:
2680
+ remote.update({dest: size for _src, dest, size in batch})
2681
  with app.state.tts_warm_lock:
2682
+ app.state.tts_warm["uploaded"] += len(batch)
2683
  except Exception as e:
2684
+ log.warning("tts bucket upload of %d files failed: %s", len(batch), e)
2685
  with app.state.tts_warm_lock:
2686
+ app.state.tts_warm["upload_failed"] += len(batch)
2687
  app.state.tts_warm["upload_error"] = str(e)[:300]
 
2688
 
2689
+ def queue_upload(src: Path | bytes, rel: str) -> None:
2690
  if mirror is None:
2691
  return
2692
  try:
 
2694
  except OSError:
2695
  return
2696
  dest = mirror.remote_path(rel)
2697
+ with uploads:
2698
+ if remote.get(dest) == size or any(d == dest for _s, d, _z in pending):
2699
+ return
2700
+ if not pending:
2701
+ oldest[0] = time.monotonic()
2702
+ pending.append((src, dest, size))
2703
+ due = len(pending) >= 25 or time.monotonic() - oldest[0] > 30
2704
+ if due:
2705
  flush()
2706
 
2707
+ def bump(**counts: int) -> None:
2708
+ with app.state.tts_warm_lock:
2709
+ for name, n in counts.items():
2710
+ app.state.tts_warm[name] += n
2711
+
2712
+ halted = threading.Event() # a phrase failed for good: no worker starts another
2713
+
2714
+ def generate(text: str) -> bool:
2715
+ """One phrase, retried on a rate limit, a provider 5xx or a dropped connection. False: stopped."""
2716
+ for attempt in range(5):
2717
+ if stop.is_set() or halted.is_set():
2718
+ return False
2719
+ try:
2720
+ data, _used = tts.wav_for_voice(text, voice)
2721
+ if mirror is not None: # the disk copy if it was kept, else the bytes themselves
2722
+ queue_upload(tts.cache_file(text, voice) or data, tts.cache_key(text, voice))
2723
+ return True
2724
+ except TTSUnavailable as e:
2725
+ if not e.transient or attempt == 4:
2726
+ raise
2727
+ delay = max(.2, min(e.retry_after or 2 ** (attempt + 1), 60))
2728
+ if e.status == 429 and hasattr(tts, "hold"):
2729
+ tts.hold(delay) # the other workers wait too, instead of hitting the limit again
2730
+ with app.state.tts_warm_lock:
2731
+ app.state.tts_warm["waiting"] = round(delay, 1)
2732
+ interrupted = stop.wait(delay) # a stop request ends the wait at once
2733
+ with app.state.tts_warm_lock:
2734
+ app.state.tts_warm["waiting"] = 0
2735
+ if interrupted:
2736
+ return False
2737
+ return False
2738
+
2739
  def run() -> None:
2740
  if mirror is not None:
2741
  try:
 
2744
  log.warning("tts bucket listing failed: %s", e)
2745
  with app.state.tts_warm_lock:
2746
  app.state.tts_warm["upload_error"] = f"listing failed: {str(e)[:300]}"
2747
+ # Already cached: skip without reading it (the cache can sit on a bucket mount, where reading each WAV is
2748
+ # a network fetch); the upload pass at the end still sends it if the bucket lacks it.
2749
+ todo = []
2750
  for text in jobs:
2751
  if stop.is_set():
2752
  break
 
 
2753
  try:
2754
+ cached = tts.cached(text, voice)
2755
+ except OSError:
2756
+ cached = False
2757
+ if cached:
2758
+ bump(skipped=1, done=1)
2759
+ else:
2760
+ todo.append(text)
2761
+ work = iter(todo)
2762
+ take = threading.Lock()
2763
+
2764
+ def worker() -> None:
2765
+ while not (stop.is_set() or halted.is_set()):
2766
+ with take:
2767
+ text = next(work, None)
2768
+ if text is None:
2769
+ return
 
 
 
 
 
 
 
 
 
 
 
2770
  with app.state.tts_warm_lock:
2771
+ app.state.tts_warm["current"] = text
2772
+ try:
2773
+ if generate(text):
2774
+ bump(generated=1, done=1)
2775
+ except Exception as e:
2776
+ log.warning("tts cache warm stopped at %r: %s", text, e)
2777
+ halted.set()
2778
+ with app.state.tts_warm_lock:
2779
+ app.state.tts_warm["failed"] += 1
2780
+ app.state.tts_warm["error"] = str(e)
2781
+
2782
+ workers = [threading.Thread(target=worker, name=f"ihki-tts-warm-{i}", daemon=True)
2783
+ for i in range(WARM_WORKERS)]
2784
+ for w in workers:
2785
+ w.start()
2786
+ for w in workers:
2787
+ w.join()
2788
+ if mirror is not None and (stop.is_set() or halted.is_set()):
2789
+ flush() # what was generated still reaches the bucket; the full pass waits for the next run
2790
  elif mirror is not None:
2791
  # also everything cached earlier: other voices, playback misses, the legacy flat layout
2792
  with app.state.tts_warm_lock:
2793
  app.state.tts_warm["current"] = "uploading cached files"
2794
  try:
2795
  for path, rel in tts.cached_files():
2796
+ queue_upload(path, rel)
2797
  except OSError as e:
2798
  log.warning("tts cache listing failed: %s", e)
2799
  flush()
app/tts.py CHANGED
@@ -34,7 +34,10 @@ CACHE_VERSION = "raef-pointed-enhanced-provider-default-v1"
34
  TIMEOUT = 60 # per socket operation: a response that keeps trickling never hits it, hence the two caps below
35
  MAX_SECONDS = 90 # one whole generation, download included
36
  MAX_AUDIO_SECONDS = 60 # the longest phrase is ~240 characters; Sawtak's broken answer is minutes of silence
37
- MIN_REQUEST_INTERVAL = .2 # spacing between Sawtak requests; the account allows 60/minute (docs/limits), 429s are retried
 
 
 
38
  # The single Sawtak voice used throughout the application.
39
  RAEF_VOICE_ID = "273d81b2-991b-5b76-8525-eeacc6a3d759"
40
  VOICES = {"רַאאִף": RAEF_VOICE_ID}
@@ -87,8 +90,11 @@ class SawtakTTS:
87
  self.api_key, self.voice_id = api_key, RAEF_VOICE_ID
88
  self.voice_name = "רַאאִף"
89
  self.cache_dir = cache_dir or Path.home() / ".cache" / "ihki-tts"
90
- self._lock = threading.Lock() # one generation at a time; also guards the voice lookup
91
- self._last_request = 0.0
 
 
 
92
  self._cache_hits = self._cache_misses = 0
93
  self.mirror: BucketMirror | None = None # set by load_tts when TTS_BUCKET is configured
94
  # With a mirror, new recordings are written here (local disk) and uploaded through the Bucket API, instead
@@ -175,12 +181,26 @@ class SawtakTTS:
175
  """Has this voice said `text` before? Then wav() answers from disk, without the provider."""
176
  return any(c.exists() for c in self._candidates(text, voice))
177
 
 
 
 
 
 
 
 
 
 
178
  def _generate(self, text: str, voice: str) -> bytes:
179
- """Generate one uncached WAV recording."""
180
- wait = MIN_REQUEST_INTERVAL - (time.monotonic() - self._last_request)
181
- if wait > 0:
182
- time.sleep(wait)
183
- self._last_request = time.monotonic()
 
 
 
 
 
184
  # Sawtak currently returns 320 seconds of silence whenever `temperature` is present,
185
  # including for 0 and 0.1. Omitting it uses the provider's deterministic supported path.
186
  with self._request("/audio/speech", {"model": MODEL, "voice": voice, "input": text,
@@ -244,11 +264,12 @@ class SawtakTTS:
244
 
245
  def _wav(self, text: str, voice: str, upload: bool = False) -> tuple[bytes, str]:
246
  """Generate or read `text` for an already validated voice id. `upload`: send a new recording to the bucket
247
- in the background (playback); the cache-warm sweep uploads its own in batches."""
248
- with self._lock:
 
249
  found = self.cache_file(text, voice) if self.staging_dir else None
250
  if found is not None: # with a mirror the cache folder may be a slow mount: no migration there
251
- self._cache_hits += 1
252
  return found.read_bytes(), voice
253
  path = self._path(text, voice)
254
  legacy = self._legacy_path(path)
@@ -260,9 +281,9 @@ class SawtakTTS:
260
  log.warning("tts cache migration failed: %s: %s", type(e).__name__, e)
261
  path = legacy
262
  if path.exists():
263
- self._cache_hits += 1
264
  return path.read_bytes(), voice
265
- self._cache_misses += 1
266
  # The caller supplies the phrase's Palestinian vowel marks. Sawtak preserves existing
267
  # marks; enhancement fills remaining pronunciation cues using the selected voice's dialect.
268
  data = self._generate(text, voice)
@@ -279,6 +300,13 @@ class SawtakTTS:
279
  self._upload_soon(data, self.cache_key(text, voice))
280
  return data, voice
281
 
 
 
 
 
 
 
 
282
  def _upload_soon(self, data: bytes, rel: str) -> None:
283
  """One playback recording to the bucket, off the request: the student does not wait for it."""
284
  mirror = self.mirror
 
34
  TIMEOUT = 60 # per socket operation: a response that keeps trickling never hits it, hence the two caps below
35
  MAX_SECONDS = 90 # one whole generation, download included
36
  MAX_AUDIO_SECONDS = 60 # the longest phrase is ~240 characters; Sawtak's broken answer is minutes of silence
37
+ # The account allows 60 requests a minute and 5 at once (GET /v1/me). Request starts are spaced to at most 45 a minute
38
+ # for this server, and at most 3 are in flight, so student playback and other clients keep some room. 429s are retried.
39
+ MIN_REQUEST_INTERVAL = 60 / 45
40
+ MAX_CONCURRENT = 3
41
  # The single Sawtak voice used throughout the application.
42
  RAEF_VOICE_ID = "273d81b2-991b-5b76-8525-eeacc6a3d759"
43
  VOICES = {"רַאאִף": RAEF_VOICE_ID}
 
90
  self.api_key, self.voice_id = api_key, RAEF_VOICE_ID
91
  self.voice_name = "רַאאִף"
92
  self.cache_dir = cache_dir or Path.home() / ".cache" / "ihki-tts"
93
+ self._lock = threading.Lock() # guards the counters and clearing the cache
94
+ self._pace = threading.Lock() # hands out request start times, MIN_REQUEST_INTERVAL apart
95
+ self._next_start = 0.0
96
+ self._in_flight = threading.Semaphore(MAX_CONCURRENT)
97
+ self._key_locks: dict[str, threading.Lock] = {} # one generation per recording at a time
98
  self._cache_hits = self._cache_misses = 0
99
  self.mirror: BucketMirror | None = None # set by load_tts when TTS_BUCKET is configured
100
  # With a mirror, new recordings are written here (local disk) and uploaded through the Bucket API, instead
 
181
  """Has this voice said `text` before? Then wav() answers from disk, without the provider."""
182
  return any(c.exists() for c in self._candidates(text, voice))
183
 
184
+ def hold(self, seconds: float) -> None:
185
+ """No new request starts for `seconds` (after a 429, so the other requests in flight back off too)."""
186
+ with self._pace:
187
+ self._next_start = max(self._next_start, time.monotonic() + seconds)
188
+
189
+ def _key_lock(self, key: str) -> threading.Lock:
190
+ with self._lock:
191
+ return self._key_locks.setdefault(key, threading.Lock())
192
+
193
  def _generate(self, text: str, voice: str) -> bytes:
194
+ """Generate one uncached WAV recording: paced, and at most MAX_CONCURRENT at once."""
195
+ with self._pace:
196
+ start = max(time.monotonic(), self._next_start)
197
+ self._next_start = start + MIN_REQUEST_INTERVAL
198
+ if start > time.monotonic():
199
+ time.sleep(start - time.monotonic())
200
+ with self._in_flight:
201
+ return self._generate_now(text, voice)
202
+
203
+ def _generate_now(self, text: str, voice: str) -> bytes:
204
  # Sawtak currently returns 320 seconds of silence whenever `temperature` is present,
205
  # including for 0 and 0.1. Omitting it uses the provider's deterministic supported path.
206
  with self._request("/audio/speech", {"model": MODEL, "voice": voice, "input": text,
 
264
 
265
  def _wav(self, text: str, voice: str, upload: bool = False) -> tuple[bytes, str]:
266
  """Generate or read `text` for an already validated voice id. `upload`: send a new recording to the bucket
267
+ in the background (playback); the cache-warm sweep uploads its own in batches. Different recordings can be
268
+ generated at the same time (see _generate); the same one waits for the first request and then reads it."""
269
+ with self._key_lock(self.cache_key(text, voice)):
270
  found = self.cache_file(text, voice) if self.staging_dir else None
271
  if found is not None: # with a mirror the cache folder may be a slow mount: no migration there
272
+ self._count(hit=True)
273
  return found.read_bytes(), voice
274
  path = self._path(text, voice)
275
  legacy = self._legacy_path(path)
 
281
  log.warning("tts cache migration failed: %s: %s", type(e).__name__, e)
282
  path = legacy
283
  if path.exists():
284
+ self._count(hit=True)
285
  return path.read_bytes(), voice
286
+ self._count(hit=False)
287
  # The caller supplies the phrase's Palestinian vowel marks. Sawtak preserves existing
288
  # marks; enhancement fills remaining pronunciation cues using the selected voice's dialect.
289
  data = self._generate(text, voice)
 
300
  self._upload_soon(data, self.cache_key(text, voice))
301
  return data, voice
302
 
303
+ def _count(self, hit: bool) -> None:
304
+ with self._lock:
305
+ if hit:
306
+ self._cache_hits += 1
307
+ else:
308
+ self._cache_misses += 1
309
+
310
  def _upload_soon(self, data: bytes, rel: str) -> None:
311
  """One playback recording to the bucket, off the request: the student does not wait for it."""
312
  mirror = self.mirror
tests/test_scoring.py CHANGED
@@ -2299,7 +2299,7 @@ def test_the_warm_sweep_stops_on_request_and_keeps_what_it_made(monkeypatch):
2299
  from app.main import create_app
2300
  from app.tts import RAEF_VOICE_ID
2301
 
2302
- phrases = [{"variants": [(word, "a")]} for word in ("واحد", "اثنين", "ثلاثة", "اربعة")]
2303
  monkeypatch.setattr(app.main, "PHRASES", phrases)
2304
  monkeypatch.setattr(app.main, "load_auth", lambda: None)
2305
  first_started, release = threading.Event(), threading.Event()
@@ -2308,20 +2308,23 @@ def test_the_warm_sweep_stops_on_request_and_keeps_what_it_made(monkeypatch):
2308
  def __init__(self): self.calls = []
2309
  def cached(self, text, voice): return False
2310
  def wav_for_voice(self, text, voice):
2311
- self.calls.append(text); first_started.set(); release.wait(5); return b"RIFF", voice
 
 
2312
  def cache_stats(self): return {"files": 0, "bytes": 0, "hits": 0, "misses": 0, "hit_rate": None}
2313
 
2314
  tts = SlowTTS()
2315
  with TestClient(create_app(scorer_for(emissions(["w"])), tts, tutor=object())) as c:
2316
- assert c.post("/api/tts-cache/warm", json={"voice_id": RAEF_VOICE_ID}).json()["total"] == 4
2317
  assert first_started.wait(5)
2318
  assert c.post("/api/tts-cache/warm/stop").status_code == 200
2319
- release.set() # the phrase in progress finishes and is kept; no further phrase starts
2320
  for _ in range(200):
2321
  state = c.get("/api/tts-cache").json()["warm"]
2322
  if not state["running"]: break
2323
  time.sleep(.01)
2324
- assert state["stopped"] is True and state["done"] == state["generated"] == 1 and len(tts.calls) == 1
 
2325
 
2326
 
2327
  def test_with_a_bucket_new_recordings_are_staged_locally_not_written_to_the_mount(monkeypatch, tmp_path):
@@ -2394,3 +2397,38 @@ def test_an_offline_copy_loads_back_into_the_cache_and_the_bucket(monkeypatch, t
2394
  if len(uploaded) == 2: break
2395
  time.sleep(.01)
2396
  assert sorted(uploaded) == sorted([f"ihki-tts/{new}", f"ihki-tts/{legacy}"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2299
  from app.main import create_app
2300
  from app.tts import RAEF_VOICE_ID
2301
 
2302
+ phrases = [{"variants": [(word, "a")]} for word in ("واحد", "اثنين", "ثلاثة", "اربعة", "خمسة")]
2303
  monkeypatch.setattr(app.main, "PHRASES", phrases)
2304
  monkeypatch.setattr(app.main, "load_auth", lambda: None)
2305
  first_started, release = threading.Event(), threading.Event()
 
2308
  def __init__(self): self.calls = []
2309
  def cached(self, text, voice): return False
2310
  def wav_for_voice(self, text, voice):
2311
+ self.calls.append(text)
2312
+ if len(self.calls) == app.main.WARM_WORKERS: first_started.set()
2313
+ release.wait(5); return b"RIFF", voice
2314
  def cache_stats(self): return {"files": 0, "bytes": 0, "hits": 0, "misses": 0, "hit_rate": None}
2315
 
2316
  tts = SlowTTS()
2317
  with TestClient(create_app(scorer_for(emissions(["w"])), tts, tutor=object())) as c:
2318
+ assert c.post("/api/tts-cache/warm", json={"voice_id": RAEF_VOICE_ID}).json()["total"] == 5
2319
  assert first_started.wait(5)
2320
  assert c.post("/api/tts-cache/warm/stop").status_code == 200
2321
+ release.set() # the phrases in progress (one per worker) finish and are kept; no further phrase starts
2322
  for _ in range(200):
2323
  state = c.get("/api/tts-cache").json()["warm"]
2324
  if not state["running"]: break
2325
  time.sleep(.01)
2326
+ workers = app.main.WARM_WORKERS
2327
+ assert state["stopped"] is True and state["done"] == state["generated"] == workers == len(tts.calls) < 5
2328
 
2329
 
2330
  def test_with_a_bucket_new_recordings_are_staged_locally_not_written_to_the_mount(monkeypatch, tmp_path):
 
2397
  if len(uploaded) == 2: break
2398
  time.sleep(.01)
2399
  assert sorted(uploaded) == sorted([f"ihki-tts/{new}", f"ihki-tts/{legacy}"])
2400
+
2401
+
2402
+ def test_the_sweep_runs_sawtak_requests_in_parallel_within_the_pace(monkeypatch, tmp_path):
2403
+ import threading
2404
+ import time
2405
+
2406
+ import app.tts as tts_module
2407
+ from app.tts import RAEF_VOICE_ID, SawtakTTS
2408
+
2409
+ monkeypatch.setattr(tts_module, "MIN_REQUEST_INTERVAL", .05)
2410
+ tts = SawtakTTS("key", cache_dir=tmp_path)
2411
+ active, peak, starts, guard = [0], [0], [], threading.Lock()
2412
+
2413
+ def slow(text, voice):
2414
+ with guard:
2415
+ active[0] += 1; peak[0] = max(peak[0], active[0]); starts.append(time.monotonic())
2416
+ time.sleep(.3)
2417
+ with guard:
2418
+ active[0] -= 1
2419
+ return b"RIFF" + text.encode()
2420
+ monkeypatch.setattr(tts, "_generate_now", slow)
2421
+ threads = [threading.Thread(target=tts.wav_for_voice, args=(f"نص {i}", RAEF_VOICE_ID)) for i in range(6)]
2422
+ t0 = time.monotonic()
2423
+ for t in threads: t.start()
2424
+ for t in threads: t.join()
2425
+ assert peak[0] == tts_module.MAX_CONCURRENT # several at once, never more than allowed
2426
+ gaps = [b - a for a, b in zip(sorted(starts), sorted(starts)[1:])]
2427
+ assert min(gaps) >= .045 and time.monotonic() - t0 < 6 * .3 # paced, yet faster than one at a time
2428
+ # the same phrase asked twice at once is generated once
2429
+ calls = []
2430
+ monkeypatch.setattr(tts, "_generate_now", lambda text, voice: (calls.append(text), time.sleep(.1), b"RIFF")[2])
2431
+ pair = [threading.Thread(target=tts.wav_for_voice, args=("مرة", RAEF_VOICE_ID)) for _ in range(2)]
2432
+ for t in pair: t.start()
2433
+ for t in pair: t.join()
2434
+ assert calls == ["مرة"]