feat(tts): add activity logging for Sawtak requests and uploads, including API endpoint for monitoring
Browse files- app/main.py +14 -2
- app/tts.py +84 -12
- static/index.html +78 -1
- tests/test_scoring.py +51 -6
app/main.py
CHANGED
|
@@ -2524,6 +2524,18 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 2524 |
app.state.refs.invalidate()
|
| 2525 |
return {"ok": True, "removed": removed}
|
| 2526 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2527 |
@app.post("/api/tts-cache/import")
|
| 2528 |
def import_tts_cache(archive: UploadFile = File(...), _a: SupabaseAuth = Depends(teacher_only)):
|
| 2529 |
"""Load an offline copy (a zip from GET /api/tts-cache/download) back into the cache: each WAV that is not
|
|
@@ -2571,7 +2583,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 2571 |
for i in range(0, len(added), 25):
|
| 2572 |
batch = added[i:i + 25]
|
| 2573 |
try:
|
| 2574 |
-
mirror.upload([(path, mirror.remote_path(rel)) for path, rel in batch])
|
| 2575 |
except Exception as e: # the next cache-warm sweep uploads what is still missing
|
| 2576 |
log.warning("tts import: bucket upload of %d files failed: %s", len(batch), e)
|
| 2577 |
threading.Thread(target=upload, name="ihki-tts-import", daemon=True).start()
|
|
@@ -2675,7 +2687,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 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:
|
|
|
|
| 2524 |
app.state.refs.invalidate()
|
| 2525 |
return {"ok": True, "removed": removed}
|
| 2526 |
|
| 2527 |
+
@app.get("/api/tts-cache/activity")
|
| 2528 |
+
def tts_cache_activity(_a: SupabaseAuth = Depends(teacher_only)):
|
| 2529 |
+
"""What the voice cache is doing: Sawtak requests and bucket uploads in flight and the latest results."""
|
| 2530 |
+
tts = app.state.tts
|
| 2531 |
+
if tts is None or not hasattr(tts, "activity"):
|
| 2532 |
+
return {"now": time.time(), "active": [], "events": [], "pace": None}
|
| 2533 |
+
from .tts import MAX_CONCURRENT, MIN_REQUEST_INTERVAL
|
| 2534 |
+
return {**tts.activity.snapshot(),
|
| 2535 |
+
"pace": {"per_minute": round(60 / MIN_REQUEST_INTERVAL) if MIN_REQUEST_INTERVAL else None,
|
| 2536 |
+
"max_concurrent": MAX_CONCURRENT,
|
| 2537 |
+
"sweep_workers": WARM_WORKERS}}
|
| 2538 |
+
|
| 2539 |
@app.post("/api/tts-cache/import")
|
| 2540 |
def import_tts_cache(archive: UploadFile = File(...), _a: SupabaseAuth = Depends(teacher_only)):
|
| 2541 |
"""Load an offline copy (a zip from GET /api/tts-cache/download) back into the cache: each WAV that is not
|
|
|
|
| 2583 |
for i in range(0, len(added), 25):
|
| 2584 |
batch = added[i:i + 25]
|
| 2585 |
try:
|
| 2586 |
+
mirror.upload([(path, mirror.remote_path(rel)) for path, rel in batch], source="import")
|
| 2587 |
except Exception as e: # the next cache-warm sweep uploads what is still missing
|
| 2588 |
log.warning("tts import: bucket upload of %d files failed: %s", len(batch), e)
|
| 2589 |
threading.Thread(target=upload, name="ihki-tts-import", daemon=True).start()
|
|
|
|
| 2687 |
if not batch:
|
| 2688 |
return
|
| 2689 |
try:
|
| 2690 |
+
mirror.upload([(src, dest) for src, dest, _size in batch], source="sweep")
|
| 2691 |
with uploads:
|
| 2692 |
remote.update({dest: size for _src, dest, size in batch})
|
| 2693 |
with app.state.tts_warm_lock:
|
app/tts.py
CHANGED
|
@@ -14,6 +14,8 @@ Env: SAWTAK_API_KEY required; without it /api/tts answers 503 and the page fa
|
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
import hashlib
|
|
|
|
|
|
|
| 17 |
import http.client
|
| 18 |
import io
|
| 19 |
import json
|
|
@@ -55,6 +57,37 @@ class TTSUnavailable(Exception):
|
|
| 55 |
self.transient = transient or status == 429 or (status is not None and status >= 500)
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
class BucketMirror:
|
| 59 |
"""Copies cached WAVs into a Hugging Face Storage Bucket through its API.
|
| 60 |
|
|
@@ -63,6 +96,7 @@ class BucketMirror:
|
|
| 63 |
|
| 64 |
def __init__(self, bucket_id: str, prefix: str = "ihki-tts", token: str | None = None):
|
| 65 |
self.bucket_id, self.prefix, self.token = bucket_id, prefix.strip("/"), token
|
|
|
|
| 66 |
|
| 67 |
def remote_path(self, rel: str) -> str:
|
| 68 |
return f"{self.prefix}/{rel}" if self.prefix else rel
|
|
@@ -78,11 +112,21 @@ class BucketMirror:
|
|
| 78 |
out[item.path] = size
|
| 79 |
return out
|
| 80 |
|
| 81 |
-
def upload(self, files: list[tuple[Path | bytes, str]]) -> None:
|
| 82 |
"""Upload (local path or bytes, bucket path) pairs in one batch."""
|
| 83 |
from huggingface_hub import batch_bucket_files
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
class SawtakTTS:
|
|
@@ -96,11 +140,22 @@ class SawtakTTS:
|
|
| 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.
|
|
|
|
| 100 |
# With a mirror, new recordings are written here (local disk) and uploaded through the Bucket API, instead
|
| 101 |
# of into cache_dir: a bucket mounted there only flushes a few files every ~30 s, so each write crawled.
|
| 102 |
self.staging_dir: Path | None = None
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def _request(self, path: str, body: dict | None = None):
|
| 105 |
req = urllib.request.Request(
|
| 106 |
BASE_URL + path,
|
|
@@ -190,15 +245,32 @@ class SawtakTTS:
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
def _generate_now(self, text: str, voice: str) -> bytes:
|
| 204 |
# Sawtak currently returns 320 seconds of silence whenever `temperature` is present,
|
|
@@ -262,7 +334,7 @@ class SawtakTTS:
|
|
| 262 |
"misses": self._cache_misses,
|
| 263 |
"hit_rate": round(self._cache_hits / requests, 4) if requests else None}
|
| 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."""
|
|
@@ -286,7 +358,7 @@ class SawtakTTS:
|
|
| 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)
|
| 290 |
if self.staging_dir is not None:
|
| 291 |
path = self.staging_dir / self.cache_key(text, voice)
|
| 292 |
try:
|
|
@@ -313,7 +385,7 @@ class SawtakTTS:
|
|
| 313 |
|
| 314 |
def go() -> None:
|
| 315 |
try:
|
| 316 |
-
mirror.upload([(data, mirror.remote_path(rel))])
|
| 317 |
except Exception as e: # the next cache-warm sweep uploads what is still missing
|
| 318 |
log.warning("tts bucket upload of %s failed: %s", rel, e)
|
| 319 |
threading.Thread(target=go, name="ihki-tts-upload", daemon=True).start()
|
|
@@ -321,11 +393,11 @@ class SawtakTTS:
|
|
| 321 |
def wav(self, text: str, voice: str = "") -> tuple[bytes, str]:
|
| 322 |
"""(WAV, voice id) in Raef's voice, or a dialogue line's configured voice."""
|
| 323 |
voice = voice if voice in LINE_VOICES.values() else RAEF_VOICE_ID
|
| 324 |
-
return self._wav(text, voice, upload=True)
|
| 325 |
|
| 326 |
-
def wav_for_voice(self, text: str, voice: str) -> tuple[bytes, str]:
|
| 327 |
"""(WAV, voice id) for a teacher-selected, validated Sawtak voice."""
|
| 328 |
-
return self._wav(text, voice)
|
| 329 |
|
| 330 |
|
| 331 |
def load_tts(key: str | None = None) -> SawtakTTS | None:
|
|
|
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
import hashlib
|
| 17 |
+
import itertools
|
| 18 |
+
from collections import deque
|
| 19 |
import http.client
|
| 20 |
import io
|
| 21 |
import json
|
|
|
|
| 57 |
self.transient = transient or status == 429 or (status is not None and status >= 500)
|
| 58 |
|
| 59 |
|
| 60 |
+
class ActivityLog:
|
| 61 |
+
"""What the voice cache is doing, for the monitoring panel: requests in flight and the latest results.
|
| 62 |
+
|
| 63 |
+
Holds phrase-bank text only (no user data), kept in memory for the life of the process."""
|
| 64 |
+
|
| 65 |
+
def __init__(self, size: int = 80):
|
| 66 |
+
self._lock = threading.Lock()
|
| 67 |
+
self._events: deque[dict] = deque(maxlen=size)
|
| 68 |
+
self._active: dict[int, dict] = {}
|
| 69 |
+
self._ids = itertools.count(1)
|
| 70 |
+
|
| 71 |
+
def begin(self, **info) -> int:
|
| 72 |
+
with self._lock:
|
| 73 |
+
n = next(self._ids)
|
| 74 |
+
self._active[n] = {**info, "id": n, "started": time.time()}
|
| 75 |
+
return n
|
| 76 |
+
|
| 77 |
+
def end(self, n: int, **result) -> None:
|
| 78 |
+
with self._lock:
|
| 79 |
+
event = self._active.pop(n, None) or {"started": time.time()}
|
| 80 |
+
now = time.time()
|
| 81 |
+
event.update(result, at=now, ms=round((now - event["started"]) * 1000))
|
| 82 |
+
self._events.appendleft(event)
|
| 83 |
+
|
| 84 |
+
def snapshot(self) -> dict:
|
| 85 |
+
with self._lock:
|
| 86 |
+
now = time.time()
|
| 87 |
+
active = [{**a, "elapsed_ms": round((now - a["started"]) * 1000)} for a in self._active.values()]
|
| 88 |
+
return {"now": now, "active": sorted(active, key=lambda a: a["started"]), "events": list(self._events)}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
class BucketMirror:
|
| 92 |
"""Copies cached WAVs into a Hugging Face Storage Bucket through its API.
|
| 93 |
|
|
|
|
| 96 |
|
| 97 |
def __init__(self, bucket_id: str, prefix: str = "ihki-tts", token: str | None = None):
|
| 98 |
self.bucket_id, self.prefix, self.token = bucket_id, prefix.strip("/"), token
|
| 99 |
+
self.activity: ActivityLog | None = None # set when attached to a SawtakTTS
|
| 100 |
|
| 101 |
def remote_path(self, rel: str) -> str:
|
| 102 |
return f"{self.prefix}/{rel}" if self.prefix else rel
|
|
|
|
| 112 |
out[item.path] = size
|
| 113 |
return out
|
| 114 |
|
| 115 |
+
def upload(self, files: list[tuple[Path | bytes, str]], source: str = "") -> None:
|
| 116 |
"""Upload (local path or bytes, bucket path) pairs in one batch."""
|
| 117 |
from huggingface_hub import batch_bucket_files
|
| 118 |
+
size = sum(len(src) if isinstance(src, bytes) else src.stat().st_size for src, _dest in files)
|
| 119 |
+
n = self.activity.begin(kind="upload", source=source, files=len(files), bytes=size,
|
| 120 |
+
text=files[0][1].rsplit("/", 1)[-1] if len(files) == 1 else "") if self.activity else 0
|
| 121 |
+
try:
|
| 122 |
+
batch_bucket_files(self.bucket_id, add=[(str(src) if isinstance(src, Path) else src, dest)
|
| 123 |
+
for src, dest in files], token=self.token)
|
| 124 |
+
except Exception as e:
|
| 125 |
+
if self.activity:
|
| 126 |
+
self.activity.end(n, status="error", message=str(e)[:200])
|
| 127 |
+
raise
|
| 128 |
+
if self.activity:
|
| 129 |
+
self.activity.end(n, status="ok")
|
| 130 |
|
| 131 |
|
| 132 |
class SawtakTTS:
|
|
|
|
| 140 |
self._in_flight = threading.Semaphore(MAX_CONCURRENT)
|
| 141 |
self._key_locks: dict[str, threading.Lock] = {} # one generation per recording at a time
|
| 142 |
self._cache_hits = self._cache_misses = 0
|
| 143 |
+
self.activity = ActivityLog() # Sawtak requests and bucket uploads, for the monitoring panel
|
| 144 |
+
self._mirror: BucketMirror | None = None # set by load_tts when TTS_BUCKET is configured
|
| 145 |
# With a mirror, new recordings are written here (local disk) and uploaded through the Bucket API, instead
|
| 146 |
# of into cache_dir: a bucket mounted there only flushes a few files every ~30 s, so each write crawled.
|
| 147 |
self.staging_dir: Path | None = None
|
| 148 |
|
| 149 |
+
@property
|
| 150 |
+
def mirror(self) -> BucketMirror | None:
|
| 151 |
+
return self._mirror
|
| 152 |
+
|
| 153 |
+
@mirror.setter
|
| 154 |
+
def mirror(self, mirror: BucketMirror | None) -> None:
|
| 155 |
+
self._mirror = mirror
|
| 156 |
+
if mirror is not None:
|
| 157 |
+
mirror.activity = self.activity
|
| 158 |
+
|
| 159 |
def _request(self, path: str, body: dict | None = None):
|
| 160 |
req = urllib.request.Request(
|
| 161 |
BASE_URL + path,
|
|
|
|
| 245 |
with self._lock:
|
| 246 |
return self._key_locks.setdefault(key, threading.Lock())
|
| 247 |
|
| 248 |
+
def _generate(self, text: str, voice: str, source: str = "") -> bytes:
|
| 249 |
"""Generate one uncached WAV recording: paced, and at most MAX_CONCURRENT at once."""
|
| 250 |
+
queued = time.monotonic()
|
| 251 |
with self._pace:
|
| 252 |
start = max(time.monotonic(), self._next_start)
|
| 253 |
self._next_start = start + MIN_REQUEST_INTERVAL
|
| 254 |
if start > time.monotonic():
|
| 255 |
time.sleep(start - time.monotonic())
|
| 256 |
with self._in_flight:
|
| 257 |
+
n = self.activity.begin(kind="sawtak", source=source, text=text[:80], voice=voice,
|
| 258 |
+
waited_ms=round((time.monotonic() - queued) * 1000))
|
| 259 |
+
try:
|
| 260 |
+
data = self._generate_now(text, voice)
|
| 261 |
+
except TTSUnavailable as e:
|
| 262 |
+
self.activity.end(n, status=e.status or "error", message=str(e)[:200])
|
| 263 |
+
raise
|
| 264 |
+
except Exception as e:
|
| 265 |
+
self.activity.end(n, status="error", message=f"{type(e).__name__}: {e}"[:200])
|
| 266 |
+
raise
|
| 267 |
+
try:
|
| 268 |
+
with wave.open(io.BytesIO(data)) as w:
|
| 269 |
+
audio = round(w.getnframes() / w.getframerate(), 2)
|
| 270 |
+
except (wave.Error, EOFError):
|
| 271 |
+
audio = None
|
| 272 |
+
self.activity.end(n, status=200, bytes=len(data), audio_s=audio)
|
| 273 |
+
return data
|
| 274 |
|
| 275 |
def _generate_now(self, text: str, voice: str) -> bytes:
|
| 276 |
# Sawtak currently returns 320 seconds of silence whenever `temperature` is present,
|
|
|
|
| 334 |
"misses": self._cache_misses,
|
| 335 |
"hit_rate": round(self._cache_hits / requests, 4) if requests else None}
|
| 336 |
|
| 337 |
+
def _wav(self, text: str, voice: str, upload: bool = False, source: str = "") -> tuple[bytes, str]:
|
| 338 |
"""Generate or read `text` for an already validated voice id. `upload`: send a new recording to the bucket
|
| 339 |
in the background (playback); the cache-warm sweep uploads its own in batches. Different recordings can be
|
| 340 |
generated at the same time (see _generate); the same one waits for the first request and then reads it."""
|
|
|
|
| 358 |
self._count(hit=False)
|
| 359 |
# The caller supplies the phrase's Palestinian vowel marks. Sawtak preserves existing
|
| 360 |
# marks; enhancement fills remaining pronunciation cues using the selected voice's dialect.
|
| 361 |
+
data = self._generate(text, voice, source)
|
| 362 |
if self.staging_dir is not None:
|
| 363 |
path = self.staging_dir / self.cache_key(text, voice)
|
| 364 |
try:
|
|
|
|
| 385 |
|
| 386 |
def go() -> None:
|
| 387 |
try:
|
| 388 |
+
mirror.upload([(data, mirror.remote_path(rel))], source="playback")
|
| 389 |
except Exception as e: # the next cache-warm sweep uploads what is still missing
|
| 390 |
log.warning("tts bucket upload of %s failed: %s", rel, e)
|
| 391 |
threading.Thread(target=go, name="ihki-tts-upload", daemon=True).start()
|
|
|
|
| 393 |
def wav(self, text: str, voice: str = "") -> tuple[bytes, str]:
|
| 394 |
"""(WAV, voice id) in Raef's voice, or a dialogue line's configured voice."""
|
| 395 |
voice = voice if voice in LINE_VOICES.values() else RAEF_VOICE_ID
|
| 396 |
+
return self._wav(text, voice, upload=True, source="playback")
|
| 397 |
|
| 398 |
+
def wav_for_voice(self, text: str, voice: str, source: str = "sweep") -> tuple[bytes, str]:
|
| 399 |
"""(WAV, voice id) for a teacher-selected, validated Sawtak voice."""
|
| 400 |
+
return self._wav(text, voice, source=source)
|
| 401 |
|
| 402 |
|
| 403 |
def load_tts(key: str | None = None) -> SawtakTTS | None:
|
static/index.html
CHANGED
|
@@ -1235,9 +1235,60 @@ html[data-subject="math"] #bootErr{color:#F08A8A}
|
|
| 1235 |
@media (prefers-reduced-motion:reduce){#startBtn,#loginBtn,#roles button,#mailForm{animation:none;opacity:1}#logo-splash{transition:none}}
|
| 1236 |
#wrap{opacity:1;transition:opacity .65s ease .1s}
|
| 1237 |
#wrap[inert]{opacity:0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1238 |
</style>
|
| 1239 |
</head>
|
| 1240 |
<body class="home">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1241 |
<aside class="install-prompt" id="installPrompt" role="dialog" aria-labelledby="installTitle" aria-describedby="installText" hidden>
|
| 1242 |
<strong id="installTitle">להתקין את אהלא וסהלא?</strong>
|
| 1243 |
<p id="installText">כך אפשר לפתוח את האתר כמו אפליקציה ישר ממסך הבית.</p>
|
|
@@ -4931,7 +4982,33 @@ function drawTtsCacheControls(box){
|
|
| 4931 |
}catch(err){ msg.textContent="המחיקה נכשלה: "+err.message; }
|
| 4932 |
button.disabled=false;
|
| 4933 |
};
|
| 4934 |
-
row.append(warm,halt,offline,load,loadFile,button,msg); card.append(title,text,usage,progress,progressLabel,row); box.append(card);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4935 |
}
|
| 4936 |
// how to get each key, step by step: a step is its parts, text or [text, link]
|
| 4937 |
const KEY_GUIDES={
|
|
|
|
| 1235 |
@media (prefers-reduced-motion:reduce){#startBtn,#loginBtn,#roles button,#mailForm{animation:none;opacity:1}#logo-splash{transition:none}}
|
| 1236 |
#wrap{opacity:1;transition:opacity .65s ease .1s}
|
| 1237 |
#wrap[inert]{opacity:0}
|
| 1238 |
+
/* Accessibility toolbar: available on the splash screen and throughout every app view. */
|
| 1239 |
+
.a11y-launch{position:fixed;top:max(12px,env(safe-area-inset-top));left:12px;z-index:100;display:grid;place-items:center;width:46px;height:46px;padding:0;border:2px solid var(--olive);border-radius:50%;background:var(--surface);color:var(--olive);box-shadow:0 3px 12px rgb(0 0 0/.2);font:700 24px/1 var(--f-body);cursor:pointer}
|
| 1240 |
+
.a11y-launch:hover,.a11y-launch:focus-visible{background:var(--olive-soft);outline:3px solid var(--surface);outline-offset:2px}
|
| 1241 |
+
.a11y-panel{position:fixed;top:max(66px,calc(env(safe-area-inset-top) + 66px));left:12px;z-index:101;width:min(310px,calc(100vw - 24px));padding:14px;border:1px solid var(--line);border-radius:14px;background:var(--surface);color:var(--ink);box-shadow:0 8px 28px rgb(0 0 0/.28)}
|
| 1242 |
+
.a11y-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.a11y-head strong{font-size:19px}.a11y-close{width:36px;height:36px;border:0;border-radius:50%;background:transparent;color:var(--ink);font:700 18px/1 var(--f-body);cursor:pointer}.a11y-close:hover,.a11y-close:focus-visible{background:var(--olive-soft);outline:2px solid var(--olive)}
|
| 1243 |
+
.a11y-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.a11y-grid button{min-height:48px;padding:8px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink);font:700 15px/1.25 var(--f-body);cursor:pointer}.a11y-grid button:hover,.a11y-grid button:focus-visible{border-color:var(--olive);outline:2px solid var(--olive);outline-offset:1px}.a11y-grid button[aria-pressed=true]{border-color:var(--olive);background:var(--olive);color:var(--on-olive)}.a11y-grid .a11y-reset{grid-column:1/-1;color:var(--sumac)}
|
| 1244 |
+
html.a11y-font-large{font-size:115%}html.a11y-font-large body{font-size:19px}html.a11y-font-xlarge{font-size:130%}html.a11y-font-xlarge body{font-size:21px}
|
| 1245 |
+
html.a11y-contrast{--bg:#000;--surface:#000;--ink:#fff;--muted:#fff;--line:#fff;--olive:#ffeb3b;--olive-deep:#ffeb3b;--on-olive:#000;--olive-soft:#242400;--sumac:#ff8da1;--sumac-bg:#310009;--ok:#70ff9b;--ok-bg:#063d18;--amber:#ffd166;--amber-bg:#3d2b00;--orange:#ffb45b;color-scheme:dark}
|
| 1246 |
+
html.a11y-contrast :focus-visible{outline:3px solid #00e5ff!important;outline-offset:3px!important}
|
| 1247 |
+
html.a11y-links a{text-decoration:underline!important;text-decoration-thickness:3px!important;text-underline-offset:3px!important}
|
| 1248 |
+
html.a11y-reduce-motion *,html.a11y-reduce-motion *::before,html.a11y-reduce-motion *::after{scroll-behavior:auto!important;animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
|
| 1249 |
+
@media(max-width:420px){.a11y-launch{top:max(8px,env(safe-area-inset-top));left:8px}.a11y-panel{left:8px;width:calc(100vw - 16px)}}
|
| 1250 |
</style>
|
| 1251 |
</head>
|
| 1252 |
<body class="home">
|
| 1253 |
+
<button class="a11y-launch" id="a11yLaunch" type="button" aria-label="פתיחת סרגל נגישות" aria-controls="a11yPanel" aria-expanded="false" title="נגישות">♿</button>
|
| 1254 |
+
<section class="a11y-panel" id="a11yPanel" aria-labelledby="a11yTitle" hidden>
|
| 1255 |
+
<div class="a11y-head"><strong id="a11yTitle">אפשרויות נגישות</strong><button class="a11y-close" id="a11yClose" type="button" aria-label="סגירת סרגל הנגישות">✕</button></div>
|
| 1256 |
+
<div class="a11y-grid">
|
| 1257 |
+
<button type="button" data-a11y="font" aria-pressed="false">טקסט גדול</button>
|
| 1258 |
+
<button type="button" data-a11y="contrast" aria-pressed="false">ניגודיות גבוהה</button>
|
| 1259 |
+
<button type="button" data-a11y="links" aria-pressed="false">הדגשת קישורים</button>
|
| 1260 |
+
<button type="button" data-a11y="motion" aria-pressed="false">הפחתת תנועה</button>
|
| 1261 |
+
<button type="button" class="a11y-reset" id="a11yReset">איפוס הגדרות</button>
|
| 1262 |
+
</div>
|
| 1263 |
+
</section>
|
| 1264 |
+
<script>
|
| 1265 |
+
/* Accessibility preferences are local to this device and are applied before the app opens. */
|
| 1266 |
+
(()=>{
|
| 1267 |
+
const root=document.documentElement, launch=document.getElementById("a11yLaunch"), panel=document.getElementById("a11yPanel"), close=document.getElementById("a11yClose");
|
| 1268 |
+
const key="ihki.accessibility.v1", defaults={font:0,contrast:false,links:false,motion:false};
|
| 1269 |
+
let prefs={...defaults};
|
| 1270 |
+
try{ prefs={...prefs,...JSON.parse(localStorage.getItem(key)||"{}")} }catch{}
|
| 1271 |
+
const buttons=()=>Object.fromEntries([...panel.querySelectorAll("[data-a11y]")].map(button=>[button.dataset.a11y,button]));
|
| 1272 |
+
const apply=()=>{
|
| 1273 |
+
root.classList.toggle("a11y-font-large",prefs.font===1); root.classList.toggle("a11y-font-xlarge",prefs.font===2);
|
| 1274 |
+
root.classList.toggle("a11y-contrast",prefs.contrast); root.classList.toggle("a11y-links",prefs.links); root.classList.toggle("a11y-reduce-motion",prefs.motion);
|
| 1275 |
+
const b=buttons(); b.font.textContent=["טקסט גדול","טקסט גדול יותר","גודל טקסט רגיל"][prefs.font]; b.font.setAttribute("aria-pressed",String(prefs.font>0));
|
| 1276 |
+
for(const name of ["contrast","links","motion"]) b[name].setAttribute("aria-pressed",String(prefs[name]));
|
| 1277 |
+
try{ localStorage.setItem(key,JSON.stringify(prefs)) }catch{}
|
| 1278 |
+
};
|
| 1279 |
+
const shut=()=>{ panel.hidden=true; launch.setAttribute("aria-expanded","false"); launch.focus({preventScroll:true}); };
|
| 1280 |
+
launch.addEventListener("click",()=>{ const opening=panel.hidden; panel.hidden=!opening; launch.setAttribute("aria-expanded",String(opening)); if(opening) close.focus({preventScroll:true}); });
|
| 1281 |
+
close.addEventListener("click",shut);
|
| 1282 |
+
panel.addEventListener("click",event=>{
|
| 1283 |
+
const button=event.target.closest("[data-a11y]"); if(!button) return;
|
| 1284 |
+
const name=button.dataset.a11y; if(name==="font") prefs.font=(prefs.font+1)%3; else prefs[name]=!prefs[name]; apply();
|
| 1285 |
+
});
|
| 1286 |
+
document.getElementById("a11yReset").addEventListener("click",()=>{ prefs={...defaults}; apply(); });
|
| 1287 |
+
document.addEventListener("keydown",event=>{ if(event.key==="Escape"&&!panel.hidden) shut(); });
|
| 1288 |
+
document.addEventListener("pointerdown",event=>{ if(!panel.hidden&&!panel.contains(event.target)&&event.target!==launch) shut(); });
|
| 1289 |
+
apply();
|
| 1290 |
+
})();
|
| 1291 |
+
</script>
|
| 1292 |
<aside class="install-prompt" id="installPrompt" role="dialog" aria-labelledby="installTitle" aria-describedby="installText" hidden>
|
| 1293 |
<strong id="installTitle">להתקין את אהלא וסהלא?</strong>
|
| 1294 |
<p id="installText">כך אפשר לפתוח את האתר כמו אפליקציה ישר ממסך הבית.</p>
|
|
|
|
| 4982 |
}catch(err){ msg.textContent="המחיקה נכשלה: "+err.message; }
|
| 4983 |
button.disabled=false;
|
| 4984 |
};
|
| 4985 |
+
row.append(warm,halt,offline,load,loadFile,button,msg); card.append(title,text,usage,progress,progressLabel,row,drawTtsActivity(card)); box.append(card);
|
| 4986 |
+
}
|
| 4987 |
+
// "what is happening now": Sawtak requests and bucket uploads in flight, and the latest results (GET /api/tts-cache/activity)
|
| 4988 |
+
function drawTtsActivity(card){
|
| 4989 |
+
const details=document.createElement("details"), summary=mark("","מה קורה עכשיו","summary"), pace=mark("msg","","div"), stats=mark("msg","","div"), active=mark("","","div"), wrap=mark("","","div"), table=document.createElement("table");
|
| 4990 |
+
wrap.style.cssText="overflow-x:auto;max-height:340px;overflow-y:auto"; table.style.cssText="width:100%;border-collapse:collapse;font-size:.85em";
|
| 4991 |
+
details.style.marginTop="12px"; summary.style.cssText="cursor:pointer;font-weight:600"; wrap.append(table); details.append(summary,pace,stats,active,wrap);
|
| 4992 |
+
const SOURCE={sweep:"הכנה",playback:"השמעה",import:"טעינה"}, secs=ms=>ms==null?"–":ms<1000?`${ms} ms`:`${(ms/1000).toFixed(1)} שנ׳`;
|
| 4993 |
+
const result=e=>e.status===200||e.status==="ok"?"✓":e.status===429?"429 · הגבלת קצב":typeof e.status==="number"&&e.status>=500?`${e.status} · תקלה אצל Sawtak`:typeof e.status==="number"?`${e.status} · נדחה`:"שגיאה";
|
| 4994 |
+
const what=e=>e.kind==="upload"?(e.files>1?`${e.files} קבצים · ${(e.bytes/1048576).toFixed(1)} MB`:(e.text||"קובץ אחד")):(e.text||"");
|
| 4995 |
+
const cell=(tr,text,head)=>{ const c=document.createElement(head?"th":"td"); c.textContent=text; c.dir="auto"; c.style.cssText="padding:4px 6px;border-bottom:1px solid #8884;text-align:start;white-space:nowrap"; tr.append(c); return c; };
|
| 4996 |
+
let timer=0;
|
| 4997 |
+
const render=data=>{
|
| 4998 |
+
const p=data.pace; pace.textContent=p?`קצב מותר לשרת הזה: עד ${p.per_minute} בקשות ל־Sawtak בדקה, עד ${p.max_concurrent} במקביל (ההכנה מריצה ${p.sweep_workers}).`:"";
|
| 4999 |
+
const recent=data.events.filter(e=>e.kind==="sawtak"&&data.now-e.at<=300), ok=recent.filter(e=>e.status===200), bad=recent.length-ok.length;
|
| 5000 |
+
const avg=ok.length?ok.reduce((n,e)=>n+e.ms,0)/ok.length:null, waited=ok.length?ok.reduce((n,e)=>n+(e.waited_ms||0),0)/ok.length:null;
|
| 5001 |
+
stats.textContent=`ב־5 הדקות האחרונות: ${recent.length} בקשות ל־Sawtak (${(recent.length/5).toFixed(1)} לדקה)`+(avg!=null?` · משך ממוצע ${secs(Math.round(avg))} · המתנה ממוצעת לתור ${secs(Math.round(waited))}`:"")+(bad?` · ${bad} נכשלו`:"");
|
| 5002 |
+
active.replaceChildren(); for(const a of data.active){ const line=mark("msg","","div"); line.dir="auto"; line.textContent=`⏳ ${a.kind==="upload"?"מעלה לדלי":"שולח ל־Sawtak"} · ${SOURCE[a.source]||a.source||""} · ${what(a)} · ${secs(a.elapsed_ms)}`; active.append(line); }
|
| 5003 |
+
if(!data.active.length) active.append(mark("msg","אין כרגע בקשות פעילות.","div"));
|
| 5004 |
+
table.replaceChildren(); const head=document.createElement("tr"); for(const h of ["שעה","יעד","מקור","מה נשלח","תוצאה","משך","המתנה בתור","אורך השמע"]) cell(head,h,true); table.append(head);
|
| 5005 |
+
for(const e of data.events){ const tr=document.createElement("tr"); cell(tr,new Date(e.at*1000).toLocaleTimeString("he-IL")); cell(tr,e.kind==="upload"?"דלי":"Sawtak"); cell(tr,SOURCE[e.source]||e.source||""); cell(tr,what(e)); const r=cell(tr,result(e)); if(e.message) r.title=e.message; if(r.textContent!=="✓") r.style.color="#c0392b"; cell(tr,secs(e.ms)); cell(tr,e.kind==="sawtak"?secs(e.waited_ms):""); cell(tr,e.audio_s!=null?`${e.audio_s} שנ׳`:""); table.append(tr); }
|
| 5006 |
+
if(!data.events.length){ const tr=document.createElement("tr"); cell(tr,"עוד לא נשלחו בקשות מאז שהשרת עלה.").colSpan=8; table.append(tr); }
|
| 5007 |
+
};
|
| 5008 |
+
const tick=()=>{ clearTimeout(timer); if(!details.open||!card.isConnected) return;
|
| 5009 |
+
api("/api/tts-cache/activity").then(async res=>{const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status); render(data);}).catch(err=>{stats.textContent="נתוני הפעילות לא זמינים: "+err.message;}).finally(()=>{ if(details.open&&card.isConnected) timer=setTimeout(tick,2000); }); };
|
| 5010 |
+
details.ontoggle=tick; details.open=true; // open by default; closing it stops the refresh
|
| 5011 |
+
return details;
|
| 5012 |
}
|
| 5013 |
// how to get each key, step by step: a step is its parts, text or [text, link]
|
| 5014 |
const KEY_GUIDES={
|
tests/test_scoring.py
CHANGED
|
@@ -547,7 +547,7 @@ def test_warm_sweep_uploads_every_cached_wav_to_the_bucket(monkeypatch, tmp_path
|
|
| 547 |
monkeypatch.setattr(app.main, "load_auth", lambda: None)
|
| 548 |
|
| 549 |
tts = SawtakTTS("key", cache_dir=tmp_path)
|
| 550 |
-
monkeypatch.setattr(tts, "_generate", lambda text, voice: b"RIFF" + text.encode())
|
| 551 |
(tmp_path / "legacy.wav").write_bytes(b"old") # cached before the per-voice layout
|
| 552 |
other = tmp_path / "other-voice"; other.mkdir(); (other / "x.wav").write_bytes(b"xx")
|
| 553 |
|
|
@@ -557,7 +557,7 @@ def test_warm_sweep_uploads_every_cached_wav_to_the_bucket(monkeypatch, tmp_path
|
|
| 557 |
self.uploads = []
|
| 558 |
def remote_sizes(self):
|
| 559 |
return {"ihki-tts/other-voice/x.wav": 2} # already in the bucket: not sent again
|
| 560 |
-
def upload(self, files):
|
| 561 |
self.uploads += [(src if isinstance(src, bytes) else src.read_bytes(), dest) for src, dest in files]
|
| 562 |
|
| 563 |
tts.mirror = mirror = FakeMirror()
|
|
@@ -2274,7 +2274,7 @@ def test_the_tts_cache_downloads_as_an_offline_zip_with_a_manifest(monkeypatch,
|
|
| 2274 |
monkeypatch.setattr(app.main, "PHRASES", phrases)
|
| 2275 |
monkeypatch.setattr(app.main, "load_auth", lambda: None)
|
| 2276 |
tts = SawtakTTS("key", cache_dir=tmp_path)
|
| 2277 |
-
monkeypatch.setattr(tts, "_generate", lambda text, voice: b"RIFF-" + text.encode())
|
| 2278 |
tts.wav(app.main.point_arabic(*phrases[0]["variants"][0]))
|
| 2279 |
(tmp_path / "stray.wav").write_bytes(b"old") # a file no phrase names is still included
|
| 2280 |
with TestClient(create_app(scorer_for(emissions(["w"])), tts, tutor=object())) as c:
|
|
@@ -2333,11 +2333,11 @@ def test_with_a_bucket_new_recordings_are_staged_locally_not_written_to_the_moun
|
|
| 2333 |
mount, staging = tmp_path / "mount", tmp_path / "staging"
|
| 2334 |
tts = SawtakTTS("key", cache_dir=mount)
|
| 2335 |
tts.staging_dir = staging
|
| 2336 |
-
monkeypatch.setattr(tts, "_generate", lambda text, voice: b"RIFF-" + text.encode())
|
| 2337 |
uploaded = []
|
| 2338 |
|
| 2339 |
class Mirror(BucketMirror):
|
| 2340 |
-
def upload(self, files): uploaded.extend(dest for _src, dest in files)
|
| 2341 |
tts.mirror = Mirror("user/bucket", "ihki-tts")
|
| 2342 |
|
| 2343 |
data, _ = tts.wav("مرحبا")
|
|
@@ -2374,7 +2374,7 @@ def test_an_offline_copy_loads_back_into_the_cache_and_the_bucket(monkeypatch, t
|
|
| 2374 |
uploaded = []
|
| 2375 |
|
| 2376 |
class Mirror(BucketMirror):
|
| 2377 |
-
def upload(self, files): uploaded.extend(dest for _src, dest in files)
|
| 2378 |
tts.mirror = Mirror("user/bucket", "ihki-tts")
|
| 2379 |
wav = b"RIFF\0\0\0\0WAVEfmt " + b"\0" * 40
|
| 2380 |
new, old, legacy = f"{RAEF_VOICE_ID}/{'a' * 40}.wav", f"{RAEF_VOICE_ID}/{'b' * 40}.wav", f"{'c' * 40}.wav"
|
|
@@ -2432,3 +2432,48 @@ def test_the_sweep_runs_sawtak_requests_in_parallel_within_the_pace(monkeypatch,
|
|
| 2432 |
for t in pair: t.start()
|
| 2433 |
for t in pair: t.join()
|
| 2434 |
assert calls == ["مرة"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
monkeypatch.setattr(app.main, "load_auth", lambda: None)
|
| 548 |
|
| 549 |
tts = SawtakTTS("key", cache_dir=tmp_path)
|
| 550 |
+
monkeypatch.setattr(tts, "_generate", lambda text, voice, *_: b"RIFF" + text.encode())
|
| 551 |
(tmp_path / "legacy.wav").write_bytes(b"old") # cached before the per-voice layout
|
| 552 |
other = tmp_path / "other-voice"; other.mkdir(); (other / "x.wav").write_bytes(b"xx")
|
| 553 |
|
|
|
|
| 557 |
self.uploads = []
|
| 558 |
def remote_sizes(self):
|
| 559 |
return {"ihki-tts/other-voice/x.wav": 2} # already in the bucket: not sent again
|
| 560 |
+
def upload(self, files, source=""):
|
| 561 |
self.uploads += [(src if isinstance(src, bytes) else src.read_bytes(), dest) for src, dest in files]
|
| 562 |
|
| 563 |
tts.mirror = mirror = FakeMirror()
|
|
|
|
| 2274 |
monkeypatch.setattr(app.main, "PHRASES", phrases)
|
| 2275 |
monkeypatch.setattr(app.main, "load_auth", lambda: None)
|
| 2276 |
tts = SawtakTTS("key", cache_dir=tmp_path)
|
| 2277 |
+
monkeypatch.setattr(tts, "_generate", lambda text, voice, *_: b"RIFF-" + text.encode())
|
| 2278 |
tts.wav(app.main.point_arabic(*phrases[0]["variants"][0]))
|
| 2279 |
(tmp_path / "stray.wav").write_bytes(b"old") # a file no phrase names is still included
|
| 2280 |
with TestClient(create_app(scorer_for(emissions(["w"])), tts, tutor=object())) as c:
|
|
|
|
| 2333 |
mount, staging = tmp_path / "mount", tmp_path / "staging"
|
| 2334 |
tts = SawtakTTS("key", cache_dir=mount)
|
| 2335 |
tts.staging_dir = staging
|
| 2336 |
+
monkeypatch.setattr(tts, "_generate", lambda text, voice, *_: b"RIFF-" + text.encode())
|
| 2337 |
uploaded = []
|
| 2338 |
|
| 2339 |
class Mirror(BucketMirror):
|
| 2340 |
+
def upload(self, files, source=""): uploaded.extend(dest for _src, dest in files)
|
| 2341 |
tts.mirror = Mirror("user/bucket", "ihki-tts")
|
| 2342 |
|
| 2343 |
data, _ = tts.wav("مرحبا")
|
|
|
|
| 2374 |
uploaded = []
|
| 2375 |
|
| 2376 |
class Mirror(BucketMirror):
|
| 2377 |
+
def upload(self, files, source=""): uploaded.extend(dest for _src, dest in files)
|
| 2378 |
tts.mirror = Mirror("user/bucket", "ihki-tts")
|
| 2379 |
wav = b"RIFF\0\0\0\0WAVEfmt " + b"\0" * 40
|
| 2380 |
new, old, legacy = f"{RAEF_VOICE_ID}/{'a' * 40}.wav", f"{RAEF_VOICE_ID}/{'b' * 40}.wav", f"{'c' * 40}.wav"
|
|
|
|
| 2432 |
for t in pair: t.start()
|
| 2433 |
for t in pair: t.join()
|
| 2434 |
assert calls == ["مرة"]
|
| 2435 |
+
|
| 2436 |
+
|
| 2437 |
+
def test_the_activity_log_shows_each_sawtak_request_and_upload(monkeypatch, tmp_path):
|
| 2438 |
+
import io
|
| 2439 |
+
import wave
|
| 2440 |
+
|
| 2441 |
+
from fastapi.testclient import TestClient
|
| 2442 |
+
|
| 2443 |
+
import app.main
|
| 2444 |
+
import app.tts as tts_module
|
| 2445 |
+
from app.main import create_app
|
| 2446 |
+
from app.tts import RAEF_VOICE_ID, BucketMirror, SawtakTTS, TTSUnavailable
|
| 2447 |
+
|
| 2448 |
+
monkeypatch.setattr(app.main, "load_auth", lambda: None)
|
| 2449 |
+
monkeypatch.setattr(tts_module, "MIN_REQUEST_INTERVAL", 0)
|
| 2450 |
+
buf = io.BytesIO()
|
| 2451 |
+
with wave.open(buf, "wb") as w:
|
| 2452 |
+
w.setnchannels(1); w.setsampwidth(2); w.setframerate(24000); w.writeframes(b"\0" * 48000)
|
| 2453 |
+
tts = SawtakTTS("key", cache_dir=tmp_path)
|
| 2454 |
+
|
| 2455 |
+
def fake(text, voice):
|
| 2456 |
+
if text == "خطأ":
|
| 2457 |
+
raise TTSUnavailable("tts provider error 429: slow down", 429)
|
| 2458 |
+
return buf.getvalue()
|
| 2459 |
+
monkeypatch.setattr(tts, "_generate_now", fake)
|
| 2460 |
+
|
| 2461 |
+
class Mirror(BucketMirror):
|
| 2462 |
+
pass
|
| 2463 |
+
import huggingface_hub
|
| 2464 |
+
monkeypatch.setattr(huggingface_hub, "batch_bucket_files", lambda *a, **k: None)
|
| 2465 |
+
tts.mirror = Mirror("user/bucket")
|
| 2466 |
+
tts.wav_for_voice("مرحبا", RAEF_VOICE_ID)
|
| 2467 |
+
try:
|
| 2468 |
+
tts.wav("خطأ")
|
| 2469 |
+
except TTSUnavailable:
|
| 2470 |
+
pass
|
| 2471 |
+
tts.mirror.upload([(b"RIFF", "ihki-tts/x.wav")], source="sweep")
|
| 2472 |
+
with TestClient(create_app(scorer_for(emissions(["w"])), tts, tutor=object())) as c:
|
| 2473 |
+
data = c.get("/api/tts-cache/activity").json()
|
| 2474 |
+
assert data["active"] == [] and data["pace"]["max_concurrent"] == tts_module.MAX_CONCURRENT
|
| 2475 |
+
upload, failed, ok = data["events"] # newest first
|
| 2476 |
+
assert upload["kind"] == "upload" and upload["status"] == "ok" and upload["files"] == 1
|
| 2477 |
+
assert failed["status"] == 429 and failed["source"] == "playback" and "slow down" in failed["message"]
|
| 2478 |
+
assert ok["kind"] == "sawtak" and ok["status"] == 200 and ok["source"] == "sweep" and ok["audio_s"] == 1.0
|
| 2479 |
+
assert ok["text"] == "مرحبا" and ok["ms"] >= 0 and ok["waited_ms"] >= 0
|