feat(tts): enhance TTS request handling with concurrency limits and pacing for improved performance
Browse files- app/main.py +92 -54
- app/tts.py +41 -13
- 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 |
-
|
|
|
|
|
|
|
|
|
|
| 2671 |
return
|
| 2672 |
try:
|
| 2673 |
-
mirror.upload([(src, dest) for src, dest, _size in
|
| 2674 |
-
|
|
|
|
| 2675 |
with app.state.tts_warm_lock:
|
| 2676 |
-
app.state.tts_warm["uploaded"] += len(
|
| 2677 |
except Exception as e:
|
| 2678 |
-
log.warning("tts bucket upload of %d files failed: %s", len(
|
| 2679 |
with app.state.tts_warm_lock:
|
| 2680 |
-
app.state.tts_warm["upload_failed"] += len(
|
| 2681 |
app.state.tts_warm["upload_error"] = str(e)[:300]
|
| 2682 |
-
pending.clear()
|
| 2683 |
|
| 2684 |
-
def
|
| 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 |
-
|
| 2693 |
-
|
| 2694 |
-
|
| 2695 |
-
|
| 2696 |
-
|
| 2697 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 2715 |
-
|
| 2716 |
-
|
| 2717 |
-
|
| 2718 |
-
|
| 2719 |
-
|
| 2720 |
-
|
| 2721 |
-
|
| 2722 |
-
|
| 2723 |
-
|
| 2724 |
-
|
| 2725 |
-
|
| 2726 |
-
|
| 2727 |
-
|
| 2728 |
-
|
| 2729 |
-
|
| 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["
|
| 2743 |
-
|
| 2744 |
-
|
| 2745 |
-
|
| 2746 |
-
|
| 2747 |
-
|
| 2748 |
-
|
| 2749 |
-
|
| 2750 |
-
|
| 2751 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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() #
|
| 91 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
| 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.
|
| 264 |
return path.read_bytes(), voice
|
| 265 |
-
self.
|
| 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)
|
|
|
|
|
|
|
| 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"] ==
|
| 2317 |
assert first_started.wait(5)
|
| 2318 |
assert c.post("/api/tts-cache/warm/stop").status_code == 200
|
| 2319 |
-
release.set() # the
|
| 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 |
-
|
|
|
|
| 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 == ["مرة"]
|