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

feat(tts): add activity logging for Sawtak requests and uploads, including API endpoint for monitoring

Browse files
Files changed (4) hide show
  1. app/main.py +14 -2
  2. app/tts.py +84 -12
  3. static/index.html +78 -1
  4. 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
- batch_bucket_files(self.bucket_id, add=[(str(src) if isinstance(src, Path) else src, dest)
85
- for src, dest in files], token=self.token)
 
 
 
 
 
 
 
 
 
 
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.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
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
- 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,
@@ -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