3v324v23 commited on
Commit
8d3d08e
·
1 Parent(s): 0cfc3a7

Add tests for GOP pooling and windowing functionality

Browse files

- Implemented tests for GOP frame pooling in `test_gop_pooling.py` to validate the scoring of synthetic CTC emissions, including various scenarios for vowel and consonant recognition.
- Created tests for class-based GOP windows in `test_gop_window.py`, ensuring proper handling of vowel and consonant transitions, substitutions, and deletions.
- Added a test for the Minerva import functionality in `test_minerva_import.py` to verify the integrity of the pilot manifest and associated phrases.

.env.example CHANGED
@@ -5,8 +5,25 @@
5
  # Without it the page falls back to the device's Arabic voice.
6
  SAWTAK_API_KEY=
7
 
8
- # Optional: a specific voice id from the Sawtak catalog.
9
- # Empty = the most liked ready Palestinian voice.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  # Google sign-in through Supabase (Project Settings -> API Keys). Both values are public: the page gets them too.
12
  # Empty = no sign-in, the service is open. On Hugging Face these can be plain Space *variables*.
@@ -20,6 +37,7 @@ TEACHER_EMAILS=
20
 
21
  # Needed by the management panel and the classes: the project's secret key (Project Settings -> API Keys -> secret, or the
22
  # legacy service_role key). It reads every student's rows, so it is NOT public: on Hugging Face set it as a *secret*.
 
23
  SUPABASE_SECRET_KEY=
24
 
25
  # Optional: accounts (separated by commas or spaces) that, like the teachers, get a flag under each score, for a recording they said
 
5
  # Without it the page falls back to the device's Arabic voice.
6
  SAWTAK_API_KEY=
7
 
8
+ # Optional: the edge-tts voice of the spoken Hebrew prompts. Empty = he-IL-HilaNeural.
9
+ HEBREW_VOICE=
10
+
11
+ # Optional: where the generated speech (Arabic answers, Hebrew prompts) is cached. Empty = ~/.cache/ihki-tts.
12
+ TTS_CACHE_DIR=
13
+
14
+ # Optional: the Hugging Face CTC phoneme model the scorer runs. Unset = facebook/wav2vec2-xlsr-53-espeak-cv-ft.
15
+ # Leave the line commented out rather than empty: an empty value is used as is and fails to load.
16
+ # scripts/bench.py and scripts/rescore.py read it too, so a benchmark runs on the same model as the server.
17
+ # MODEL_ID=facebook/wav2vec2-xlsr-53-espeak-cv-ft
18
+
19
+ # Optional: how a vowel's GOP pools its frames (docs/gop-pooling.md): max | mean | topk | lse | energy | gated.
20
+ # Empty = gated. "max" is the scoring before pooling. The lab's results show it beside the version, to compare runs.
21
+ GOP_RATIO_POOL=
22
+
23
+ # Optional: which frames a vowel's competitors are read from. Empty = wide (from the sound before to the one after,
24
+ # as consonants). "narrow" = its aligned frames +- 1, or a number for another margin. Narrow drops spikes in the
25
+ # transitions, but also misses a substitution held just outside it; see scripts/gop_window_bench.py.
26
+ GOP_VOWEL_WINDOW=
27
 
28
  # Google sign-in through Supabase (Project Settings -> API Keys). Both values are public: the page gets them too.
29
  # Empty = no sign-in, the service is open. On Hugging Face these can be plain Space *variables*.
 
37
 
38
  # Needed by the management panel and the classes: the project's secret key (Project Settings -> API Keys -> secret, or the
39
  # legacy service_role key). It reads every student's rows, so it is NOT public: on Hugging Face set it as a *secret*.
40
+ # scripts/bench.py needs it (with the two values above) to read the lab's recordings.
41
  SUPABASE_SECRET_KEY=
42
 
43
  # Optional: accounts (separated by commas or spaces) that, like the teachers, get a flag under each score, for a recording they said
app/align.py CHANGED
@@ -5,11 +5,46 @@ Nothing here depends on torch, so it is unit-testable with synthetic emissions.
5
  """
6
  from __future__ import annotations
7
 
8
- from dataclasses import dataclass
 
 
9
 
10
  import numpy as np
11
 
 
 
12
  NEG = -1e30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  @dataclass
@@ -87,53 +122,155 @@ class GopConfig:
87
  # Softens the target/competitor ratio: at 0.5 a competitor twice as likely still scores 71, not 50.
88
  # The zero-shot model is noisy on accented speech, so a linear ratio punishes its own uncertainty.
89
  leniency: float = 0.5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
 
92
  def score_tokens(logp: np.ndarray, align: Alignment, tokens: list[int], blank: int,
93
- cfg: GopConfig = GopConfig(), accept: list[set[int] | None] | None = None,
94
- n_real: int | None = None, named: set[int] | None = None) -> list[TokenScore]:
95
- """Per-token GOP.
 
 
 
96
 
97
- The window of token i runs from the end of token i-1 to the start of token i+1, so it covers
98
- the blank frames around the emission where a substituted phoneme typically peaks.
99
  The neighbours' own labels are excluded from the competitor set (coarticulation is not an error).
100
-
101
  A class token (a virtual column pooling its correct realisations, `accept[i]` their ids) is scored on the
102
  pooled column; its members are not competitors. Competitors come from the first `n_real` columns only, and
103
  one outside `named` is reported as `other` rather than by name.
 
 
 
 
104
  """
105
- T = logp.shape[0]
106
- accept = accept or [None] * len(tokens)
107
- out: list[TokenScore] = []
108
- for i, tok in enumerate(tokens):
109
- a = align.spans[i - 1][1] if i > 0 else 0
110
- b = align.spans[i + 1][0] if i + 1 < len(tokens) else T
111
- a, b = min(a, align.spans[i][0]), max(b, align.spans[i][1])
112
- seg = logp[a:b]
113
- peak = float(seg[:, tok].max())
114
- members = accept[i] or set()
115
- realised = max(members, key=lambda m: seg[:, m].max()) if members else None
116
-
117
- masked = (seg[:, :n_real] if n_real else seg).max(0).copy()
118
- masked[blank] = NEG
119
- for j in (i - 1, i, i + 1):
120
- if 0 <= j < len(tokens):
121
- for x in {tokens[j], *(accept[j] or ())}:
122
- if x < len(masked):
123
- masked[x] = NEG
124
- comp = int(masked.argmax())
125
- comp_peak = float(masked[comp])
126
-
127
- gop = peak - max(peak, comp_peak)
128
- deleted = bool(peak < cfg.deleted_peak and comp_peak < cfg.deleted_comp)
129
- confidence = min(1.0, float(np.exp(peak)) / cfg.confident_peak)
130
- score = 100.0 * float(np.exp(gop * cfg.leniency)) * float(np.sqrt(confidence))
131
- present = comp_peak > NEG / 2
132
- other = bool(present and named is not None and comp not in named)
133
- out.append(TokenScore(round(score, 1), round(gop, 3), round(peak, 3),
134
- comp if present and not other else None, deleted, realised, other,
135
- round(comp_peak, 3), (int(a), int(b))))
136
- return out
137
 
138
 
139
  def greedy_decode(logp: np.ndarray, blank: int) -> list[int]:
 
5
  """
6
  from __future__ import annotations
7
 
8
+ import logging
9
+ import os
10
+ from dataclasses import dataclass, field
11
 
12
  import numpy as np
13
 
14
+ log = logging.getLogger("ihki.align")
15
+
16
  NEG = -1e30
17
+ POOLS = ("max", "mean", "topk", "lse", "energy", "gated")
18
+ DEFAULT_POOL = "gated"
19
+
20
+
21
+ def env_pool() -> str:
22
+ """The ratio pool GOP_RATIO_POOL names (read when a GopConfig is made, so after main.py loads .env).
23
+ Empty or unset: DEFAULT_POOL. An unknown name is logged and DEFAULT_POOL is used."""
24
+ pool = os.environ.get("GOP_RATIO_POOL", "").strip().lower() or DEFAULT_POOL
25
+ if pool not in POOLS:
26
+ log.warning("GOP_RATIO_POOL=%r is not one of %s: using %s", pool, ", ".join(POOLS), DEFAULT_POOL)
27
+ return DEFAULT_POOL
28
+ return pool
29
+
30
+
31
+ NARROW_PAD = 1
32
+
33
+
34
+ def env_vowel_window() -> int | None:
35
+ """GOP_VOWEL_WINDOW: empty, unset or "wide" (None: a vowel reads the consonants' wide window), "narrow" (its
36
+ aligned span +- NARROW_PAD frames), or that pad as a frame count. Anything else is logged and "wide" used.
37
+ Wide is the default: with a pooled ratio it absorbs a transition spike without the narrow window's blind spot
38
+ (docs/gop-pooling.md, scripts/gop_window_bench.py)."""
39
+ raw = os.environ.get("GOP_VOWEL_WINDOW", "").strip().lower()
40
+ if raw in ("", "wide"):
41
+ return None
42
+ if raw == "narrow":
43
+ return NARROW_PAD
44
+ if raw.isdigit():
45
+ return int(raw)
46
+ log.warning("GOP_VOWEL_WINDOW=%r is not wide, narrow or a frame count: using wide", raw)
47
+ return None
48
 
49
 
50
  @dataclass
 
122
  # Softens the target/competitor ratio: at 0.5 a competitor twice as likely still scores 71, not 50.
123
  # The zero-shot model is noisy on accented speech, so a linear ratio punishes its own uncertainty.
124
  leniency: float = 0.5
125
+ # How the target/competitor ratio pools the window's frames (docs/gop-pooling.md). The peak behind
126
+ # `confidence` and `deleted` stays the max, so a peaky CTC emission is never diluted by its blanks.
127
+ ratio_pool: str = field(default_factory=env_pool) # max | mean | topk | lse | energy | gated (GOP_RATIO_POOL)
128
+ pool_k: int = 2 # topk: frames averaged per column
129
+ pool_tau: float = 0.5 # lse: temperature (0 -> max, 1 -> mean)
130
+ energy_gamma: float = 1.0 # energy: frame weight = (RMS / loudest in the window) ** gamma
131
+ energy_gate: float = 0.3 # gated: frames quieter than this fraction of the window's loudest are dropped
132
+ energy_spread: int = 1 # frames the energy is max-filtered over (CTC peaks lead/lag the acoustics)
133
+ pool_vowels_only: bool = True # consonants keep the max (bursts and voiceless sounds are short and quiet)
134
+ # A vowel's competitors are read from its aligned span +- this many frames (None: the wide window consonants
135
+ # use). Its presence (`peak`, `deleted`) is still read from the wide window. GOP_VOWEL_WINDOW sets it.
136
+ vowel_window: int | None = field(default_factory=env_vowel_window)
137
+
138
+
139
+ def pool_frames(seg: np.ndarray, how: str = "max", k: int = 2, tau: float = 0.5,
140
+ weight: np.ndarray | None = None) -> np.ndarray:
141
+ """Pool a window's (n, V) log-posteriors over time into one log-score per column.
142
+
143
+ max: the single best frame. topk: log of the mean of each column's k best frames. lse: the power mean
144
+ tau * log(mean(p ** (1/tau))), between the max (tau -> 0) and the mean (tau = 1). mean / energy / gated:
145
+ log of the weighted mean posterior, `weight` (uniform when None) normalised over the window.
146
+ """
147
+ n = len(seg)
148
+ if how == "max" or n == 1:
149
+ return seg.max(0)
150
+ if how == "topk":
151
+ k = max(1, min(k, n))
152
+ return np.logaddexp.reduce(np.partition(seg, n - k, axis=0)[n - k:], axis=0) - np.log(k)
153
+ if how == "lse":
154
+ return tau * (np.logaddexp.reduce(seg / tau, axis=0) - np.log(n))
155
+ w = np.ones(n) if weight is None else np.asarray(weight, dtype=np.float64)
156
+ w = w / w.sum() if w.sum() > 0 else np.full(n, 1.0 / n)
157
+ return np.logaddexp.reduce(seg + np.log(np.maximum(w, 1e-300))[:, None], axis=0)
158
+
159
+
160
+ def energy_weights(energy: np.ndarray, how: str, gamma: float = 1.0, gate: float = 0.3) -> np.ndarray:
161
+ """Frame weights of one window from its (already smoothed) RMS: soft (`energy`) or a 0/1 voice gate (`gated`)."""
162
+ e = energy / max(float(energy.max()), 1e-12)
163
+ return (e >= gate).astype(np.float64) if how == "gated" else e ** gamma
164
+
165
+
166
+ def spread_energy(energy: np.ndarray, spread: int) -> np.ndarray:
167
+ """Max-filter over +-spread frames: a CTC emission a frame off the loud nucleus still gets its weight."""
168
+ out = energy.astype(np.float64).copy()
169
+ for s in range(1, spread + 1):
170
+ out[s:] = np.maximum(out[s:], energy[:-s])
171
+ out[:-s] = np.maximum(out[:-s], energy[s:])
172
+ return out
173
+
174
+
175
+ def token_window(spans: list[tuple[int, int]], i: int, T: int, pad: int | None = None) -> tuple[int, int]:
176
+ """[start, end) frames token i is read from. Wide (pad None): from the end of token i-1 to the start of token i+1,
177
+ the blanks around the emission where a substituted consonant peaks. Narrow: its own span +- `pad` frames, inside
178
+ the wide window (a vowel is its syllable's loud nucleus, not a sound hiding in the blanks)."""
179
+ a = spans[i - 1][1] if i > 0 else 0
180
+ b = spans[i + 1][0] if i + 1 < len(spans) else T
181
+ a, b = min(a, spans[i][0]), max(b, spans[i][1])
182
+ if pad is None:
183
+ return a, b
184
+ return max(a, spans[i][0] - pad), min(b, spans[i][1] + pad)
185
+
186
+
187
+ def compute_token_gop(logp: np.ndarray, spans: list[tuple[int, int]], i: int, tokens: list[int], blank: int,
188
+ cfg: GopConfig, vowel: bool = False, accept: list[set[int] | None] | None = None,
189
+ n_real: int | None = None, named: set[int] | None = None, energy: np.ndarray | None = None,
190
+ edge: tuple[set[int], set[int]] | None = None, pooled: bool | None = None) -> TokenScore:
191
+ """GOP of token i (see score_tokens), its window and pooling chosen by its class.
192
+
193
+ Presence is read from the wide window for every token: `peak` (behind the confidence) and `deleted` (target under
194
+ deleted_peak and no competitor over deleted_comp), so a vowel aligned to a single frame is still found deleted.
195
+ The competitor ratio of a vowel is read from its narrow window (cfg.vowel_window) and pooled with cfg.ratio_pool;
196
+ a consonant keeps the wide window, and the max unless cfg.pool_vowels_only is off. `pooled` overrides the latter.
197
+ `edge`: ids that are no competitor on the frames between the token's span and its left / right neighbour (a vowel
198
+ gliding into j or w passes through i or u: coarticulation, not a substitution). `energy` is already spread.
199
+ """
200
+ T = logp.shape[0]
201
+ accept = accept or [None] * len(tokens)
202
+ a, b = token_window(spans, i, T)
203
+ ra, rb = token_window(spans, i, T, cfg.vowel_window) if vowel and cfg.vowel_window is not None else (a, b)
204
+ tok, (s0, s1) = tokens[i], spans[i]
205
+ seg = logp[a:b]
206
+ peak = float(seg[:, tok].max())
207
+ members = accept[i] or set()
208
+ realised = max(members, key=lambda m: seg[:, m].max()) if members else None
209
+
210
+ cols = (seg[:, :n_real] if n_real else seg).copy()
211
+ if edge:
212
+ for ids, lo, hi in ((edge[0], a, s0), (edge[1], s1, b)):
213
+ ids = [x for x in ids if x < cols.shape[1]]
214
+ if ids and hi > lo:
215
+ cols[lo - a:hi - a, ids] = NEG
216
+ mask = np.zeros(cols.shape[1], dtype=bool)
217
+ mask[blank] = True
218
+ for j in (i - 1, i, i + 1):
219
+ if 0 <= j < len(tokens):
220
+ for x in {tokens[j], *(accept[j] or ())}:
221
+ if x < len(mask):
222
+ mask[x] = True
223
+ deleted = bool(peak < cfg.deleted_peak and float(np.where(mask, NEG, cols.max(0)).max()) < cfg.deleted_comp)
224
+
225
+ how = cfg.ratio_pool if (pooled if pooled is not None else vowel or not cfg.pool_vowels_only) else "max"
226
+ weight = None
227
+ if how in ("energy", "gated"):
228
+ if energy is not None:
229
+ weight = energy_weights(energy[ra:rb], how, cfg.energy_gamma, cfg.energy_gate)
230
+ weight[max(0, s0 - ra):max(0, s1 - ra)] = 1.0 # the frames the token was emitted on count in full
231
+ how = "mean"
232
+ rcols = cols[ra - a:rb - a]
233
+ maxed = rcols.max(0)
234
+ ratio = maxed.copy() if how == "max" else pool_frames(rcols, how, cfg.pool_k, cfg.pool_tau, weight)
235
+ ratio[mask] = NEG
236
+ comp = int(ratio.argmax())
237
+ comp_peak = float(maxed[comp]) if ratio[comp] > NEG / 2 else NEG
238
+ tseg = logp[ra:rb, [tok]]
239
+ target = float(tseg.max()) if how == "max" else float(pool_frames(tseg, how, cfg.pool_k, cfg.pool_tau, weight)[0])
240
+
241
+ gop = target - max(target, float(ratio[comp]))
242
+ confidence = min(1.0, float(np.exp(peak)) / cfg.confident_peak)
243
+ score = 100.0 * float(np.exp(gop * cfg.leniency)) * float(np.sqrt(confidence))
244
+ present = comp_peak > NEG / 2
245
+ other = bool(present and named is not None and comp not in named)
246
+ return TokenScore(round(score, 1), round(gop, 3), round(peak, 3), comp if present and not other else None,
247
+ deleted, realised, other, round(comp_peak, 3), (int(ra), int(rb)))
248
 
249
 
250
  def score_tokens(logp: np.ndarray, align: Alignment, tokens: list[int], blank: int,
251
+ cfg: GopConfig | None = None, accept: list[set[int] | None] | None = None,
252
+ n_real: int | None = None, named: set[int] | None = None,
253
+ energy: np.ndarray | None = None, pooled: list[bool] | None = None,
254
+ vowels: list[bool] | None = None,
255
+ edges: list[tuple[set[int], set[int]] | None] | None = None) -> list[TokenScore]:
256
+ """Per-token GOP (compute_token_gop for each token).
257
 
 
 
258
  The neighbours' own labels are excluded from the competitor set (coarticulation is not an error).
 
259
  A class token (a virtual column pooling its correct realisations, `accept[i]` their ids) is scored on the
260
  pooled column; its members are not competitors. Competitors come from the first `n_real` columns only, and
261
  one outside `named` is reported as `other` rather than by name.
262
+
263
+ `vowels` marks the tokens that are vowels (narrow window, pooled ratio). Without it every token is read from the
264
+ wide window and pooled, unless `pooled` marks which are. `energy` is the per-frame RMS the energy pools need;
265
+ without it they fall back to the plain mean. `edges[i]`: see compute_token_gop.
266
  """
267
+ cfg = cfg or GopConfig()
268
+ if energy is not None:
269
+ energy = spread_energy(np.asarray(energy)[:logp.shape[0]], cfg.energy_spread)
270
+ return [compute_token_gop(logp, align.spans, i, tokens, blank, cfg, bool(vowels and vowels[i]), accept, n_real,
271
+ named, energy, edges[i] if edges else None,
272
+ (pooled[i] if pooled is not None else None if vowels is not None else True))
273
+ for i in range(len(tokens))]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
 
276
  def greedy_decode(logp: np.ndarray, blank: int) -> list[int]:
app/auth.py CHANGED
@@ -455,6 +455,22 @@ class SupabaseAuth:
455
  log.warning("a recording not stored (is there a storage bucket `%s`? see the README): %s", bucket, e)
456
  return False
457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  def _get_object(self, bucket: str, name: str) -> tuple[bytes, str]:
459
  """A recording of a private storage bucket and its media type."""
460
  req = urllib.request.Request(
@@ -513,7 +529,7 @@ class SupabaseAuth:
513
 
514
  def lab_sample(self, sample_id: int) -> dict | None:
515
  """One lab recording's row, without the scorer's result, or None."""
516
- query = urllib.parse.urlencode({"select": "id,item,speaker,label,mistake,audio", "id": f"eq.{int(sample_id)}",
517
  "limit": 1})
518
  rows = self._admin(f"/rest/v1/lab_samples?{query}")
519
  return rows[0] if rows else None
@@ -523,6 +539,17 @@ class SupabaseAuth:
523
  row = self.lab_sample(sample_id)
524
  return self._get_object("lab", row["audio"]) if row and row.get("audio") else None
525
 
 
 
 
 
 
 
 
 
 
 
 
526
  def calibrations(self, status: str | None = None) -> list[dict]:
527
  """Calibration proposals, optionally filtered by their workflow status."""
528
  params = {"select": "*", "order": "created_at.desc"}
 
455
  log.warning("a recording not stored (is there a storage bucket `%s`? see the README): %s", bucket, e)
456
  return False
457
 
458
+ def _delete_objects(self, bucket: str, names: list[str]) -> bool:
459
+ """Removes recordings of a private storage bucket; False (logged) when some could not be removed."""
460
+ ok = True
461
+ for k in range(0, len(names), 1000): # the storage API takes up to 1000 names a call
462
+ req = urllib.request.Request(
463
+ f"{self.url}/storage/v1/object/{bucket}", method="DELETE",
464
+ data=json.dumps({"prefixes": names[k:k + 1000]}).encode(),
465
+ headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
466
+ "Content-Type": "application/json", "User-Agent": "ihki/1.0"})
467
+ try:
468
+ urllib.request.urlopen(req, timeout=TIMEOUT).close()
469
+ except (urllib.error.URLError, TimeoutError) as e:
470
+ log.warning("recordings of the storage bucket `%s` not removed: %s", bucket, e)
471
+ ok = False
472
+ return ok
473
+
474
  def _get_object(self, bucket: str, name: str) -> tuple[bytes, str]:
475
  """A recording of a private storage bucket and its media type."""
476
  req = urllib.request.Request(
 
529
 
530
  def lab_sample(self, sample_id: int) -> dict | None:
531
  """One lab recording's row, without the scorer's result, or None."""
532
+ query = urllib.parse.urlencode({"select": "id,item,ar,ipa,speaker,label,mistake,audio", "id": f"eq.{int(sample_id)}",
533
  "limit": 1})
534
  rows = self._admin(f"/rest/v1/lab_samples?{query}")
535
  return rows[0] if rows else None
 
539
  row = self.lab_sample(sample_id)
540
  return self._get_object("lab", row["audio"]) if row and row.get("audio") else None
541
 
542
+ def delete_lab_samples(self, speaker: str | None = None) -> int:
543
+ """Deletes the lab's recordings of one speaker ("": the unnamed), or of every speaker when None: the rows,
544
+ then their files in the bucket `lab` (a file that cannot be removed is logged and left). Returns how many rows."""
545
+ where = {"speaker": f"eq.{speaker}"} if speaker is not None else {"id": "gt.0"} # a DELETE needs a filter
546
+ rows = self._admin(f"/rest/v1/lab_samples?{urllib.parse.urlencode({**where, 'select': 'id,audio'})}",
547
+ "DELETE") or []
548
+ names = [r["audio"] for r in rows if r.get("audio")]
549
+ if names:
550
+ self._delete_objects("lab", names)
551
+ return len(rows)
552
+
553
  def calibrations(self, status: str | None = None) -> list[dict]:
554
  """Calibration proposals, optionally filtered by their workflow status."""
555
  params = {"select": "*", "order": "created_at.desc"}
app/lab.py CHANGED
@@ -7,6 +7,10 @@ Three groups:
7
  - pairs: minimal pairs (or a three) that differ in that sound only (حال / هال / خال, صيف / سيف ...): can the
8
  model tell them apart
9
  - positions: the sound at the start, the middle and the end of a word
 
 
 
 
10
 
11
  The recordings are kept in the Supabase table `lab_samples` and the storage bucket `lab` (see CREATE_LAB in main.py),
12
  the material for a per-sound benchmark: how often a sound said right is flagged, and how often a planted mistake is not.
@@ -14,9 +18,12 @@ The IPA follows the bank's (phrases.py), and a word the bank has keeps the bank'
14
  """
15
  from __future__ import annotations
16
 
 
 
 
17
  from .translit import to_hebrew
18
 
19
- GROUPS = {"syllables": "הברות", "pairs": "זוגות מינימליים", "positions": "מיקום במילה"}
20
 
21
 
22
  def _item(id: str, group: str, focus: str, he: str, *variants: tuple[str, str], pair: str = "") -> dict:
@@ -73,6 +80,20 @@ ITEMS = [
73
  _item("pos-dˤ-3", "positions", "dˤ", "לבן", ("ابيض", "ʔ a b j a dˤ")),
74
  _item("pos-ʔ-2", "positions", "ʔ", "שאלה", ("سؤال", "s u ʔ aː l")),
75
  _item("pos-h-2", "positions", "h", "חודש", ("شهر", "ʃ a h r"), ("شهر", "ʃ a h i r")),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  ]
77
  BY_ID = {item["id"]: item for item in ITEMS}
78
 
@@ -82,6 +103,22 @@ def phrase(item: dict) -> dict:
82
  return {"cat": "lab", "key": f"lab:{item['id']}", "he": item["he"], "variants": item["variants"]}
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  def public_items() -> list[dict]:
86
  """The list for the lab's page (teachers only): each item with its first reading's sounds, which a planted
87
  mistake is marked on, and that reading in Hebrew letters."""
@@ -194,7 +231,8 @@ def benchmark(samples: list[dict], results: dict | None = None, reports: list[di
194
  def compact(result: dict) -> dict:
195
  """What the benchmark reads of a result (the sounds, their scores and flags), for the page to send back."""
196
  keep = ("ipa", "sound", "score", "flagged", "heard")
197
- return {"score": result.get("score"), "unrelated": bool(result.get("unrelated")),
 
198
  "words": [{"phonemes": [{k: p[k] for k in keep if k in p} for p in w.get("phonemes", [])]}
199
  for w in result.get("words", [])]}
200
 
@@ -206,3 +244,56 @@ def still_flagged(result: dict, issue: dict) -> bool:
206
  if not 0 <= issue.get("word", -1) < len(words):
207
  return False
208
  return any(p.get("flagged") and p["ipa"] == issue.get("expected") for p in words[issue["word"]].get("phonemes", []))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  - pairs: minimal pairs (or a three) that differ in that sound only (حال / هال / خال, صيف / سيف ...): can the
8
  model tell them apart
9
  - positions: the sound at the start, the middle and the end of a word
10
+ - vowels: a short vowel beside a glide (ولد, يلّا, شويّ: the glide's colour on the transition frames, GLIDE_KIN),
11
+ between consonants (كلب, بنت, كلّ: a spike from a neighbour's release), and short / long (من / مين).
12
+ Planting a↔i or u↔a on these measures whether the GOP pooling and windows (docs/gop-pooling.md)
13
+ still catch a real vowel substitution
14
 
15
  The recordings are kept in the Supabase table `lab_samples` and the storage bucket `lab` (see CREATE_LAB in main.py),
16
  the material for a per-sound benchmark: how often a sound said right is flagged, and how often a planted mistake is not.
 
18
  """
19
  from __future__ import annotations
20
 
21
+ from .align import POOLS
22
+ from .scorer import LETTER_GOOD
23
+ from .tips import VOWELS
24
  from .translit import to_hebrew
25
 
26
+ GROUPS = {"syllables": "הברות", "pairs": "זוגות מינימליים", "positions": "מיקום במילה", "vowels": "תנועות"}
27
 
28
 
29
  def _item(id: str, group: str, focus: str, he: str, *variants: tuple[str, str], pair: str = "") -> dict:
 
80
  _item("pos-dˤ-3", "positions", "dˤ", "לבן", ("ابيض", "ʔ a b j a dˤ")),
81
  _item("pos-ʔ-2", "positions", "ʔ", "שאלה", ("سؤال", "s u ʔ aː l")),
82
  _item("pos-h-2", "positions", "h", "חודש", ("شهر", "ʃ a h r"), ("شهر", "ʃ a h i r")),
83
+ # vowels: the short vowel is the focus. Beside a glide: its i / u colour must not count against the vowel
84
+ _item("vow-walad", "vowels", "a", "ילד", ("ولد", "w a l a d")),
85
+ _item("vow-jalla", "vowels", "a", "יאללה", ("يلّا", "j a l l a")),
86
+ _item("vow-ʃwajj", "vowels", "a", "קצת", ("شويّ", "ʃ w a j j")),
87
+ _item("vow-majj", "vowels", "a", "מים", ("ميّ", "m a j j"), ("ميّ", "m a j j e")),
88
+ # between consonants: a release or a burst beside the vowel must not count against it
89
+ _item("vow-kalb", "vowels", "a", "כלב", ("كلب", "k a l b")),
90
+ _item("vow-bint", "vowels", "i", "בת", ("بنت", "b i n t")),
91
+ _item("vow-kull", "vowels", "u", "הכל", ("كلّ", "k u l l")),
92
+ _item("vow-sukkar", "vowels", "u", "סוכר", ("سكّر", "s u k k a r")),
93
+ _item("vow-bass", "vowels", "a", "רק / די", ("بسّ", "b a s s")),
94
+ # short / long: the same consonants, the vowel's length only
95
+ _item("vow-min", "vowels", "i", "מ־ (מן)", ("من", "m i n"), pair="i / iː"),
96
+ _item("vow-miin", "vowels", "iː", "מי", ("مين", "m iː n"), pair="i / iː"),
97
  ]
98
  BY_ID = {item["id"]: item for item in ITEMS}
99
 
 
103
  return {"cat": "lab", "key": f"lab:{item['id']}", "he": item["he"], "variants": item["variants"]}
104
 
105
 
106
+ def sample_phrase(sample: dict) -> dict | None:
107
+ """The phrase of a saved recording, including imported lines outside the fixed diagnostic list.
108
+
109
+ Fixed items keep their current variants. An importer stores the exact Arabic/IPA pair that was used for
110
+ scoring, so later rescores, sweeps and traces do not depend on the importer's manifest still being present.
111
+ """
112
+ item = BY_ID.get(sample.get("item"))
113
+ if item is not None:
114
+ return phrase(item)
115
+ ar, ipa = sample.get("ar"), sample.get("ipa")
116
+ if not ar or not ipa:
117
+ return None
118
+ key = str(sample.get("item") or f"sample:{sample.get('id', '')}")
119
+ return {"cat": "lab", "key": key, "he": sample.get("speaker") or key, "variants": [(ar, ipa)]}
120
+
121
+
122
  def public_items() -> list[dict]:
123
  """The list for the lab's page (teachers only): each item with its first reading's sounds, which a planted
124
  mistake is marked on, and that reading in Hebrew letters."""
 
231
  def compact(result: dict) -> dict:
232
  """What the benchmark reads of a result (the sounds, their scores and flags), for the page to send back."""
233
  keep = ("ipa", "sound", "score", "flagged", "heard")
234
+ return {"score": result.get("score"), "heard_ipa": result.get("heard_ipa", ""),
235
+ "unrelated": bool(result.get("unrelated")),
236
  "words": [{"phonemes": [{k: p[k] for k in keep if k in p} for p in w.get("phonemes", [])]}
237
  for w in result.get("words", [])]}
238
 
 
244
  if not 0 <= issue.get("word", -1) < len(words):
245
  return False
246
  return any(p.get("flagged") and p["ipa"] == issue.get("expected") for p in words[issue["word"]].get("phonemes", []))
247
+
248
+
249
+ # ---------- the sweep over the GOP settings ----------
250
+ # Every GOP_RATIO_POOL with every GOP_VOWEL_WINDOW that differs in practice (a larger pad reaches the wide window).
251
+ WINDOWS = (None, 0, 1, 2)
252
+ SWEEP = [(pool, window) for pool in POOLS for window in WINDOWS]
253
+
254
+
255
+ def sweep_key(pool: str, window: int | None) -> str:
256
+ """The name of a setting, as the env writes it: "gated|wide", "max|1"."""
257
+ return f"{pool}|{'wide' if window is None else window}"
258
+
259
+
260
+ SWEEP_KEYS = {sweep_key(p, w): (p, w) for p, w in SWEEP}
261
+
262
+
263
+ def quality(totals: dict) -> float | None:
264
+ """The pipeline's one number, 0..100: balanced accuracy over the judged sounds, the mean of
265
+ (1 - false rejection rate) and the detection rate. Each half is a rate, so the many sounds said right do not
266
+ drown the few planted mistakes, and a setting cannot win by flagging nothing (detection 0) or everything
267
+ (false rejections 1). Without planted mistakes it is 1 - frr alone (see `quality_partial`). None: no sound judged."""
268
+ frr, det = totals.get("frr"), totals.get("detection")
269
+ if frr is None:
270
+ return None
271
+ return round(100 * ((1 - frr) + det) / 2, 1) if det is not None else round(100 * (1 - frr), 1)
272
+
273
+
274
+ def quality_partial(totals: dict) -> bool:
275
+ """True when `quality` could not weigh the detection (no planted mistake was judged)."""
276
+ return totals.get("detection") is None
277
+
278
+
279
+ def vowel_stats(samples: list[dict], results: dict | None = None) -> dict:
280
+ """The vowels of the recordings, by score rather than flag: the scorer flags no vowel but the u of rule 3.11, so the
281
+ flag counts of `benchmark` cannot see a GOP change on vowels. Over recordings of the item's sentence (not
282
+ "unrelated"): {"right": vowels said right, "mean": their mean score, "weak": the share of them under LETTER_GOOD
283
+ (shown to the learner as weak), "planted": planted vowel mistakes, "auc": the chance that a vowel said right
284
+ scores above a planted wrong one (ties count half; None without planted vowels)}."""
285
+ results = results or {}
286
+ right, wrong = [], []
287
+ for s in samples:
288
+ result = results.get(s.get("id")) or s.get("result") or {}
289
+ if not result.get("words") or result.get("unrelated"):
290
+ continue
291
+ for x in judged(s, result):
292
+ if x["sound"] in VOWELS:
293
+ (wrong if x["planted"] else right).append(x["score"])
294
+ auc = None
295
+ if right and wrong:
296
+ auc = round(sum(1.0 if r > w else 0.5 if r == w else 0.0 for r in right for w in wrong) / (len(right) * len(wrong)), 3)
297
+ return {"right": len(right), "mean": round(sum(right) / len(right), 1) if right else None,
298
+ "weak": round(sum(x < LETTER_GOOD for x in right) / len(right), 3) if right else None,
299
+ "planted": len(wrong), "auc": auc}
app/main.py CHANGED
@@ -35,7 +35,7 @@ from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
35
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
36
  from .reference import References
37
  from .rules import TOGGLES, check_detectable
38
- from .scorer import Scorer, decode_audio, load_scorer
39
  from .speech_evaluation import SpeechEvaluationService
40
  from .texts import read_line, text_phrases
41
  from .tts import LINE_VOICES, RAEF_VOICE_ID, VOICES, SawtakTTS, TTSUnavailable, load_tts
@@ -778,20 +778,82 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
778
  speakers = sorted({s.get("speaker") or "" for s in samples})
779
  if speaker:
780
  samples = [s for s in samples if (s.get("speaker") or "") == speaker]
 
 
 
 
 
781
  return {**lab.benchmark(samples, reports=reports), "speakers": speakers, "model": MODEL_ID,
782
- "version": build_version(), "samples": [s["id"] for s in samples if s.get("audio")]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783
 
784
  @app.post("/api/lab/samples/{sample_id}/rescore")
785
- def lab_rescore_sample(sample_id: int, a: SupabaseAuth = Depends(teacher_only)):
 
786
  """A lab recording scored again by the current scorer (as practice scores, with the native baseline), nothing
787
  kept: {"id", "result": what the benchmark reads of it (lab.compact)}. The page rescores the recordings one by
788
- one and sends the results to POST /api/lab/results."""
 
 
 
 
789
  try:
790
  row = a.lab_sample(sample_id)
791
  found = a.lab_audio(sample_id) if row else None
792
  except AuthUnavailable as e:
793
  raise lab_error(e)
794
- if row is None or row["item"] not in lab.BY_ID:
 
795
  raise HTTPException(404, "no such lab recording")
796
  if found is None:
797
  raise HTTPException(404, "להקלטה הזו אין קובץ שמור")
@@ -799,9 +861,124 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
799
  wav = decode_audio(found[0])
800
  except ValueError:
801
  raise HTTPException(400, "could not decode audio")
802
- phrase = lab.phrase(lab.BY_ID[row["item"]])
803
- refs = app.state.refs.get(phrase["key"], phrase) if app.state.refs else []
804
- return {"id": sample_id, "result": lab.compact(app.state.scorer.score(wav, phrase, refs))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
 
806
  @app.post("/api/lab/results")
807
  def lab_rescored_results(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only)):
@@ -823,8 +1000,11 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
823
  except AuthUnavailable as e:
824
  log.warning("confirmed flags not read for the lab: %s", e)
825
  reports = []
 
826
  return {"now": lab.benchmark(samples, results, reports), "stored": lab.benchmark(samples, reports=reports),
827
- "model": MODEL_ID, "version": build_version()}
 
 
828
 
829
  @app.post("/api/lab/flags/{flag_id}/rescore")
830
  def lab_rescore_flag(flag_id: int, a: SupabaseAuth = Depends(teacher_only)):
 
35
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
36
  from .reference import References
37
  from .rules import TOGGLES, check_detectable
38
+ from .scorer import SAMPLE_RATE, Scorer, decode_audio, frame_energy, load_scorer
39
  from .speech_evaluation import SpeechEvaluationService
40
  from .texts import read_line, text_phrases
41
  from .tts import LINE_VOICES, RAEF_VOICE_ID, VOICES, SawtakTTS, TTSUnavailable, load_tts
 
778
  speakers = sorted({s.get("speaker") or "" for s in samples})
779
  if speaker:
780
  samples = [s for s in samples if (s.get("speaker") or "") == speaker]
781
+ # the recordings themselves, newest first, for the trace dashboard's picker
782
+ recordings = [{"id": s["id"], "item": s["item"], "speaker": s.get("speaker") or "", "label": s.get("label"),
783
+ "mistake": s.get("mistake"), "score": s.get("score"), "flagged": s.get("flagged") or [],
784
+ "unrelated": bool((s.get("result") or {}).get("unrelated")), "created_at": s.get("created_at")}
785
+ for s in reversed(samples) if s.get("audio")]
786
  return {**lab.benchmark(samples, reports=reports), "speakers": speakers, "model": MODEL_ID,
787
+ "version": build_version(), "samples": [s["id"] for s in samples if s.get("audio")],
788
+ "recordings": recordings}
789
+
790
+ @app.get("/api/lab/minerva")
791
+ def lab_minerva(a: SupabaseAuth = Depends(teacher_only)):
792
+ """The imported Minerva pilot, grouped into videos for the lab's native-speech runner."""
793
+ titles = {"U8aBRd3-wlM": "במכולת", "OuAwxXUZBU4": "אסיפת הורים", "V2y6LqnoXTo": "הקדרה"}
794
+ files = {"U8aBRd3-wlM": "במכולת * פִי א-דֻכַּאן * في الدّكّان * קטע שיחה בערבית מדוברת [U8aBRd3-wlM].webm",
795
+ "OuAwxXUZBU4": "אסיפת הורים * אִגתִמַאע אַהַאלִי * إجتماع أهالي * קטע שיחה בערבית מדוברת [OuAwxXUZBU4].webm",
796
+ "V2y6LqnoXTo": "הקְדֵרָה הולידה קְדֵרָה * אִ-טֻנגָ'רָה וִלדַת טֻנגָ'רָה * الطّنجرة ولدت طنجرة * סיפור בערבית מדוברת [V2y6LqnoXTo].mkv"}
797
+ try:
798
+ samples = a.lab_samples(full=True)
799
+ except AuthUnavailable as e:
800
+ raise lab_error(e)
801
+ videos: dict[str, dict] = {}
802
+ for s in samples:
803
+ match = re.fullmatch(r"minerva:([\w-]{11}):(\d+)", s.get("item") or "")
804
+ if (s.get("speaker") or "") != "מינרווה" or not match:
805
+ continue
806
+ video_id, line = match.group(1), int(match.group(2))
807
+ timing = re.fullmatch(rf"minerva:{re.escape(video_id)}:([0-9.]+)-([0-9.]+)", s.get("device") or "")
808
+ stored = s.get("result") or {}
809
+ video = videos.setdefault(video_id, {"id": video_id, "title": titles.get(video_id, video_id),
810
+ "filename": files.get(video_id, ""), "samples": []})
811
+ video["samples"].append({"id": s["id"], "item": s["item"], "line": line,
812
+ "start": float(timing.group(1)) if timing else None,
813
+ "end": float(timing.group(2)) if timing else None,
814
+ "ar": s.get("ar") or "", "ipa": s.get("ipa") or "",
815
+ "score": s.get("score"), "heard_ipa": s.get("heard_ipa") or "",
816
+ "flagged": s.get("flagged") or [],
817
+ "unrelated": bool(stored.get("unrelated")), "audio": bool(s.get("audio"))})
818
+ order = list(titles)
819
+ out = list(videos.values())
820
+ out.sort(key=lambda v: order.index(v["id"]) if v["id"] in order else len(order))
821
+ for video in out:
822
+ video["samples"].sort(key=lambda s: s["line"])
823
+ return {"videos": out, "model": MODEL_ID, "version": build_version()}
824
+
825
+ @app.delete("/api/lab/samples")
826
+ def lab_delete_samples(speaker: str | None = Query(None), everyone: bool = Query(False),
827
+ a: SupabaseAuth = Depends(teacher_only)):
828
+ """Deletes the lab's recordings, rows and files, to record the list afresh: one `speaker`'s ("" the unnamed),
829
+ or with `everyone` every speaker's. One of the two is required, so a missing parameter never deletes all.
830
+ {"deleted": how many recordings}."""
831
+ if (speaker is None) == (not everyone):
832
+ raise HTTPException(400, "give a speaker, or everyone=true")
833
+ try:
834
+ n = a.delete_lab_samples(None if everyone else " ".join(speaker.split())[:MAX_SPEAKER])
835
+ except AuthUnavailable as e:
836
+ raise lab_error(e)
837
+ log.info("lab: %d recordings deleted (%s)", n, "every speaker" if everyone else f"speaker {speaker!r}")
838
+ return {"deleted": n}
839
 
840
  @app.post("/api/lab/samples/{sample_id}/rescore")
841
+ def lab_rescore_sample(sample_id: int, trace: bool = Query(False), setting: str = Query(""),
842
+ a: SupabaseAuth = Depends(teacher_only)):
843
  """A lab recording scored again by the current scorer (as practice scores, with the native baseline), nothing
844
  kept: {"id", "result": what the benchmark reads of it (lab.compact)}. The page rescores the recordings one by
845
+ one and sends the results to POST /api/lab/results.
846
+ `trace`: the whole result with the teacher's stage-by-stage trace instead, for the trace dashboard. `setting`
847
+ (a lab.SWEEP key, "gated|wide"): scored under that GOP setting, its native baseline rebuilt under it too."""
848
+ if setting and setting not in lab.SWEEP_KEYS:
849
+ raise HTTPException(400, "unknown setting")
850
  try:
851
  row = a.lab_sample(sample_id)
852
  found = a.lab_audio(sample_id) if row else None
853
  except AuthUnavailable as e:
854
  raise lab_error(e)
855
+ phrase = lab.sample_phrase(row) if row else None
856
+ if phrase is None:
857
  raise HTTPException(404, "no such lab recording")
858
  if found is None:
859
  raise HTTPException(404, "להקלטה הזו אין קובץ שמור")
 
861
  wav = decode_audio(found[0])
862
  except ValueError:
863
  raise HTTPException(400, "could not decode audio")
864
+ scorer = app.state.scorer
865
+ if setting:
866
+ pool, window = lab.SWEEP_KEYS[setting]
867
+ scorer = scorer.with_gop(ratio_pool=pool, vowel_window=window)
868
+ refs = sweep_references(scorer, phrase, setting)
869
+ else:
870
+ refs = app.state.refs.get(phrase["key"], phrase) if app.state.refs else []
871
+ result = scorer.score(wav, phrase, refs, diagnostics=trace)
872
+ if not trace:
873
+ return {"id": sample_id, "result": lab.compact(result)}
874
+ g = scorer.cfg.gop
875
+ return {"id": sample_id, "result": result, "setting": lab.sweep_key(g.ratio_pool, g.vowel_window),
876
+ "sample": {k: row.get(k) for k in ("id", "item", "speaker", "label", "mistake")}}
877
+
878
+ # The sweep (POST /api/lab/samples/{id}/sweep) rebuilds each native baseline under every setting: the voices'
879
+ # posteriors are kept by (voice, wav hash), their References by setting too, for the life of the server.
880
+ sweep_emits: dict[tuple[str, str], tuple] = {}
881
+ sweep_refs: dict[tuple, object] = {}
882
+
883
+ def sweep_references(scorer: Scorer, phrase: dict, setting: str) -> list:
884
+ """The native baseline of `phrase` under `scorer`'s setting, from the TTS voices whose WAV is cached (the first
885
+ voice's is made on the way, as a rescore makes it). Empty without TTS."""
886
+ refs = app.state.refs
887
+ if refs is None:
888
+ return []
889
+ refs.get(phrase["key"], phrase)
890
+ text, out = point_arabic(*phrase["variants"][0]), []
891
+ for voice in refs.voices:
892
+ try:
893
+ if not refs.tts.cached(text, voice):
894
+ continue
895
+ data, _ = refs.tts.wav(text, voice)
896
+ except Exception as e: # provider down: score without this voice, as References does
897
+ log.warning("sweep: no %s voice for %r: %s", voice, text, e)
898
+ continue
899
+ emitted = (voice, hashlib.sha1(data).hexdigest())
900
+ if emitted not in sweep_emits:
901
+ if len(sweep_emits) > 400:
902
+ sweep_emits.clear(); sweep_refs.clear()
903
+ wav = decode_audio(data)
904
+ logp = app.state.scorer.emit(wav)
905
+ sweep_emits[emitted] = (logp, frame_energy(wav, len(logp)))
906
+ key = (*emitted, phrase["key"], setting, scorer.disabled)
907
+ if key not in sweep_refs:
908
+ logp, energy = sweep_emits[emitted]
909
+ sweep_refs[key] = scorer.reference_logp(logp, phrase, energy)
910
+ if sweep_refs[key] is not None:
911
+ out.append(sweep_refs[key])
912
+ return out
913
+
914
+ @app.post("/api/lab/samples/{sample_id}/sweep")
915
+ def lab_sweep_sample(sample_id: int, a: SupabaseAuth = Depends(teacher_only)):
916
+ """A lab recording scored under each GOP setting of lab.SWEEP (GOP_RATIO_POOL x GOP_VOWEL_WINDOW), nothing kept.
917
+ The model runs once; each setting scores its posteriors against the native baseline rebuilt under it.
918
+ {"id", "results": {setting: what the benchmark reads (lab.compact)}}; the page sends them to POST /api/lab/sweep."""
919
+ try:
920
+ row = a.lab_sample(sample_id)
921
+ found = a.lab_audio(sample_id) if row else None
922
+ except AuthUnavailable as e:
923
+ raise lab_error(e)
924
+ phrase = lab.sample_phrase(row) if row else None
925
+ if phrase is None:
926
+ raise HTTPException(404, "no such lab recording")
927
+ if found is None:
928
+ raise HTTPException(404, "להקלטה הזו אין קובץ שמור")
929
+ try:
930
+ wav = decode_audio(found[0])
931
+ except ValueError:
932
+ raise HTTPException(400, "could not decode audio")
933
+ base = app.state.scorer
934
+ logp = base.emit(wav)
935
+ energy, duration = frame_energy(wav, len(logp)), len(wav) / SAMPLE_RATE
936
+ results = {}
937
+ for setting, (pool, window) in lab.SWEEP_KEYS.items():
938
+ scorer = base.with_gop(ratio_pool=pool, vowel_window=window)
939
+ refs = sweep_references(scorer, phrase, setting)
940
+ results[setting] = lab.compact(scorer.score_logp(logp, phrase, refs, False, duration, energy))
941
+ return {"id": sample_id, "results": results}
942
+
943
+ @app.post("/api/lab/sweep")
944
+ def lab_sweep_results(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only)):
945
+ """The benchmark of every setting from the sweeps of the recordings ({"results": {sample id: {setting: result}}}):
946
+ {"settings": [{"key", "pool", "window", "quality", "partial", "totals", "vowels" (lab.vowel_stats),
947
+ "changed": how many sound scores differ from max|wide's, "sounds"}] best first, "current": the server's own
948
+ setting, "recordings", "model", "version"}. `quality` is lab.quality (balanced accuracy)."""
949
+ given = body.get("results")
950
+ if not isinstance(given, dict) or len(given) > 5000:
951
+ raise HTTPException(400, "invalid results")
952
+ try:
953
+ results = {int(k): v for k, v in given.items() if isinstance(v, dict)}
954
+ except ValueError:
955
+ raise HTTPException(400, "invalid results")
956
+ try:
957
+ samples = [s for s in a.lab_samples(full=True) if s["id"] in results]
958
+ except AuthUnavailable as e:
959
+ raise lab_error(e)
960
+ keep = ("recordings", "unrelated", "right", "false_flags", "planted", "caught", "frr", "detection", "good_share")
961
+ ref = lab.sweep_key("max", None) # the scoring before pooling and windows: what each setting changed of it
962
+ scores = lambda r: [p.get("score") for w in r.get("words", []) for p in w.get("phonemes", [])]
963
+ settings = []
964
+ for setting, (pool, window) in lab.SWEEP_KEYS.items():
965
+ per = {i: r[setting] for i, r in results.items() if isinstance(r.get(setting), dict)}
966
+ mine = [s for s in samples if s["id"] in per]
967
+ totals = lab.benchmark(mine, per)["totals"]
968
+ settings.append({"key": setting, "pool": pool, "window": "wide" if window is None else window,
969
+ "quality": lab.quality(totals), "partial": lab.quality_partial(totals),
970
+ "totals": {k: totals[k] for k in keep}, "vowels": lab.vowel_stats(mine, per),
971
+ # the sounds this setting scored otherwise than `ref`, of the sounds both scored
972
+ "changed": sum(a != b for i, r in results.items() if isinstance(r.get(ref), dict)
973
+ and i in per for a, b in zip(scores(per[i]), scores(r[ref]))),
974
+ "sounds": sum(len(scores(per[i])) for i in per)})
975
+ # by quality; its ties (the flags rarely see a vowel) by how well the vowels separate, then how few are weak
976
+ settings.sort(key=lambda r: (r["quality"] is None, -(r["quality"] or 0), -(r["vowels"]["auc"] or 0),
977
+ r["vowels"]["weak"] if r["vowels"]["weak"] is not None else 1,
978
+ -(r["vowels"]["mean"] or 0)))
979
+ g = app.state.scorer.cfg.gop
980
+ return {"settings": settings, "current": lab.sweep_key(g.ratio_pool, g.vowel_window), "recordings": len(samples),
981
+ "model": MODEL_ID, "version": build_version()}
982
 
983
  @app.post("/api/lab/results")
984
  def lab_rescored_results(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only)):
 
1000
  except AuthUnavailable as e:
1001
  log.warning("confirmed flags not read for the lab: %s", e)
1002
  reports = []
1003
+ cfg = getattr(app.state.scorer, "cfg", None) # the env, not the code, picks the pool: the version cannot tell
1004
  return {"now": lab.benchmark(samples, results, reports), "stored": lab.benchmark(samples, reports=reports),
1005
+ "model": MODEL_ID, "version": build_version(),
1006
+ "pool": cfg.gop.ratio_pool if cfg else None,
1007
+ "vowel_window": ("wide" if cfg.gop.vowel_window is None else f"±{cfg.gop.vowel_window}") if cfg else None}
1008
 
1009
  @app.post("/api/lab/flags/{flag_id}/rescore")
1010
  def lab_rescore_flag(flag_id: int, a: SupabaseAuth = Depends(teacher_only)):
app/scorer.py CHANGED
@@ -1,6 +1,7 @@
1
  """Pronunciation scorer: phoneme CTC model + forced alignment against the expected phonemes."""
2
  from __future__ import annotations
3
 
 
4
  import json
5
  import logging
6
  import os
@@ -19,6 +20,8 @@ from .tips import VOWELS, letter_reason, sound_name, tip_for
19
  from .translit import CONSONANTS as _HEB_C, HEARD_ONLY as _HEB_HEARD, VOWELS as _HEB_V, to_hebrew, word_letters, word_to_hebrew
20
 
21
  HEBREW_SOUNDS = set(_HEB_C) | set(_HEB_V) | set(_HEB_HEARD)
 
 
22
 
23
  log = logging.getLogger("ihki.scorer")
24
 
@@ -250,6 +253,13 @@ class Scorer:
250
  self.named = {i for i in (vocab.sound_id(n) for n in names) if i is not None}
251
  self.disabled: frozenset[str] = frozenset() # rules.TOGGLES the teacher switched off (ניטור)
252
 
 
 
 
 
 
 
 
253
  def set_disabled(self, ids) -> None:
254
  """Atomically replaces the switched-off rules; unknown ids are ignored."""
255
  self.disabled = frozenset(ids) & TOGGLE_IDS
@@ -279,6 +289,13 @@ class Scorer:
279
 
280
  _GLIDES = {"w", "j"} # the way into or out of a glide sounds like a vowel in anyone's speech
281
 
 
 
 
 
 
 
 
282
  def _insertions(self, logp: np.ndarray, toks: list[TargetToken], al: Alignment) -> dict[int, str]:
283
  """Rules 3.10: token -> the vowel the model clearly heard between it and the consonant before it, in one word
284
  (katiir for ktiir). The cluster that opens the sentence is spared (6.3 #16), and so are pairs with w or j."""
@@ -321,7 +338,11 @@ class Scorer:
321
  """The baseline from a native recording of the phrase, or None when the model does not
322
  recognise the phrase in it (then it says nothing about single sounds)."""
323
  logp = self.emit(wav)
324
- fit = self._fit(self.vocab.merge(logp), phrase, frame_energy(wav, len(logp)))
 
 
 
 
325
  if fit is None:
326
  return None
327
  if fit.unrelated or float(np.mean([sc.score for sc in fit.scores])) < self.cfg.ref_min_mean:
@@ -457,7 +478,8 @@ class Scorer:
457
  ids = [t.id for t in toks]
458
  ar_words = ar.split()
459
  scores = score_tokens(ext, al, ids, blank, self.cfg.gop, [vocab.members.get(i) for i in ids],
460
- vocab.n_real, self.named if self._on("unnamed_sounds") else None)
 
461
  gop_scores, exempted = list(scores), [None] * len(scores)
462
  for i, (t, sc) in enumerate(zip(toks, scores)):
463
  heard = vocab.i2t.get(sc.competitor) if sc.competitor is not None else None
 
1
  """Pronunciation scorer: phoneme CTC model + forced alignment against the expected phonemes."""
2
  from __future__ import annotations
3
 
4
+ import copy
5
  import json
6
  import logging
7
  import os
 
20
  from .translit import CONSONANTS as _HEB_C, HEARD_ONLY as _HEB_HEARD, VOWELS as _HEB_V, to_hebrew, word_letters, word_to_hebrew
21
 
22
  HEBREW_SOUNDS = set(_HEB_C) | set(_HEB_V) | set(_HEB_HEARD)
23
+ # a vowel gliding into (or out of) j or w passes through these: on the frames between the two, coarticulation
24
+ GLIDE_KIN = {"j": ("i", "iː", "e", "eː"), "w": ("u", "uː", "o", "oː")}
25
 
26
  log = logging.getLogger("ihki.scorer")
27
 
 
253
  self.named = {i for i in (vocab.sound_id(n) for n in names) if i is not None}
254
  self.disabled: frozenset[str] = frozenset() # rules.TOGGLES the teacher switched off (ניטור)
255
 
256
+ def with_gop(self, **changes) -> "Scorer":
257
+ """This scorer with some GopConfig fields changed (ratio_pool, vowel_window ...), sharing its model, vocab and
258
+ rules: for the lab's sweep over the settings. Nothing of this scorer changes."""
259
+ other = copy.copy(self)
260
+ other.cfg = replace(self.cfg, gop=replace(self.cfg.gop, **changes))
261
+ return other
262
+
263
  def set_disabled(self, ids) -> None:
264
  """Atomically replaces the switched-off rules; unknown ids are ignored."""
265
  self.disabled = frozenset(ids) & TOGGLE_IDS
 
289
 
290
  _GLIDES = {"w", "j"} # the way into or out of a glide sounds like a vowel in anyone's speech
291
 
292
+ def _glide_edges(self, toks: list[TargetToken]) -> list[tuple[set[int], set[int]] | None]:
293
+ """For a vowel beside j or w: the vowels that glide is made of (GLIDE_KIN), no competitor on the frames
294
+ between the vowel and that glide."""
295
+ kin = lambda k: {x for p in GLIDE_KIN.get(toks[k].phoneme, ()) if (x := self.vocab.sound_id(p)) is not None}
296
+ return [(kin(i - 1) if i else set(), kin(i + 1) if i + 1 < len(toks) else set())
297
+ if t.phoneme in VOWELS else None for i, t in enumerate(toks)]
298
+
299
  def _insertions(self, logp: np.ndarray, toks: list[TargetToken], al: Alignment) -> dict[int, str]:
300
  """Rules 3.10: token -> the vowel the model clearly heard between it and the consonant before it, in one word
301
  (katiir for ktiir). The cluster that opens the sentence is spared (6.3 #16), and so are pairs with w or j."""
 
338
  """The baseline from a native recording of the phrase, or None when the model does not
339
  recognise the phrase in it (then it says nothing about single sounds)."""
340
  logp = self.emit(wav)
341
+ return self.reference_logp(logp, phrase, frame_energy(wav, len(logp)))
342
+
343
+ def reference_logp(self, logp: np.ndarray, phrase: dict, energy: np.ndarray | None = None) -> Reference | None:
344
+ """Scorer.reference from the model's posteriors of the native recording (`emit`), and its frame energy."""
345
+ fit = self._fit(self.vocab.merge(logp), phrase, energy)
346
  if fit is None:
347
  return None
348
  if fit.unrelated or float(np.mean([sc.score for sc in fit.scores])) < self.cfg.ref_min_mean:
 
478
  ids = [t.id for t in toks]
479
  ar_words = ar.split()
480
  scores = score_tokens(ext, al, ids, blank, self.cfg.gop, [vocab.members.get(i) for i in ids],
481
+ vocab.n_real, self.named if self._on("unnamed_sounds") else None, energy,
482
+ vowels=[t.phoneme in VOWELS for t in toks], edges=self._glide_edges(toks))
483
  gop_scores, exempted = list(scores), [None] * len(scores)
484
  for i, (t, sc) in enumerate(zip(toks, scores)):
485
  heard = vocab.i2t.get(sc.competitor) if sc.competitor is not None else None
data/minerva_metadata.json ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "file_name": "במכולת * פִי א-דֻכַּאן * في الدّكّان * קטע שיחה בערבית מדוברת [U8aBRd3-wlM].webm",
4
+ "youtube_id": "U8aBRd3-wlM",
5
+ "title_hebrew": "במכולת",
6
+ "segments": [
7
+ {
8
+ "voice_time_seconds": {"start": 9.0, "end": 10.8},
9
+ "arabic": "فيه عندك حليب طازة؟",
10
+ "hebrew_transliteration": "פִיה עִנְדַכּ חַלִיב טַאזֶה?",
11
+ "hebrew": "יש לך חלב טרי?",
12
+ "expected_phonemes": "f iː h | ʕ i n d a k | ħ a l iː b | tˤ aː z e"
13
+ },
14
+ {
15
+ "voice_time_seconds": {"start": 10.8, "end": 12.8},
16
+ "arabic": "آه، جوّا في التلّاجة، على إيدك اليمين.",
17
+ "hebrew_transliteration": "אַה, גֻ'וַּא פִי ת-תַּלַּאגֶ'ה, עַלַא אִידֶכּ לְ-יַמִין.",
18
+ "hebrew": "כן, בפנים במקרר, מימינך.",
19
+ "expected_phonemes": "ʔ aː | dʒ u w a | f i | t t a l aː dʒ e | ʕ a l a | ʔ iː d e k | l j a m iː n"
20
+ },
21
+ {
22
+ "voice_time_seconds": {"start": 12.8, "end": 14.2},
23
+ "arabic": "ممتاز.",
24
+ "hebrew_transliteration": "מֻמְתַאז.",
25
+ "hebrew": "מצוין.",
26
+ "expected_phonemes": "m u m t aː z"
27
+ },
28
+ {
29
+ "voice_time_seconds": {"start": 14.2, "end": 17.6},
30
+ "arabic": "ممكن تحطّلي كمان وقيّة جبنة صفرا؟",
31
+ "hebrew_transliteration": "מֻמְכּן תְחֻטְלִי כַּמַאן וַקִיַּת גֻ'בְּנֶה צַפְרַא?",
32
+ "hebrew": "אפשר לשים לי גם חבילת גבינה צהובה?",
33
+ "expected_phonemes": "m u m k n | t ħ u tˤ l i | k a m aː n | w a ʔ i j a t | dʒ u b n e | sˤ a f r a"
34
+ },
35
+ {
36
+ "voice_time_seconds": {"start": 17.6, "end": 21.8},
37
+ "arabic": "آه طبعًا، بس ثواني أخلّص معاه الحساب...",
38
+ "hebrew_transliteration": "אַה טַבְּעַן, בַּס תְוַאנִי אַחְ'לַצ מַעַאה לְ-חִסַאב.",
39
+ "hebrew": "כן, כמובן, רק רגע שאסיים איתו את החשבון.",
40
+ "expected_phonemes": "ʔ aː | tˤ a b ʕ a n | b a s | t w aː n i | ʔ a x l a sˤ | m a ʕ aː h | l ħ i s aː b"
41
+ },
42
+ {
43
+ "voice_time_seconds": {"start": 21.8, "end": 24.6},
44
+ "arabic": "تفضّلي. شكرًا!",
45
+ "hebrew_transliteration": "תְפַדַّל. שֻכְּרַן!",
46
+ "hebrew": "בבקשה. תודה!",
47
+ "expected_phonemes": "t f a dˤ dˤ a l | ʃ u k r a n"
48
+ },
49
+ {
50
+ "voice_time_seconds": {"start": 24.6, "end": 27.6},
51
+ "arabic": "هذا هو؟ أيوة، قديش الحساب؟",
52
+ "hebrew_transliteration": "הַאדַא הֻוֶּ? אַיוַה, קַדֵּיש לְ-חִסַאב?",
53
+ "hebrew": "זהו? כן, כמה החשבון?",
54
+ "expected_phonemes": "h aː d a | h u w e | ʔ a j w a | ʔ a d eː ʃ | l ħ i s aː b"
55
+ },
56
+ {
57
+ "voice_time_seconds": {"start": 27.6, "end": 30.4},
58
+ "arabic": "٢٥ شيكل. تفضّل.",
59
+ "hebrew_transliteration": "חַ'מְסֶה וְעִשְרִין שֵיקֶל. תְפַדַّל.",
60
+ "hebrew": "עשרים וחמישה שקל. בבקשה.",
61
+ "expected_phonemes": "x a m s e | u ʕ i ʃ r iː n | ʃ eː ʔ e l | t f a dˤ dˤ a l"
62
+ },
63
+ {
64
+ "voice_time_seconds": {"start": 30.4, "end": 33.0},
65
+ "arabic": "معكيش أصغر؟ معيش صرافة ميّة...",
66
+ "hebrew_transliteration": "מַעְכִּיש אַצְעַ'ר? מַעִיש צַרַאפֶת מִיֶּה.",
67
+ "hebrew": "אין לך קטן יותר? אין לי עודף ממאה.",
68
+ "expected_phonemes": "m a ʕ k iː ʃ | ʔ a z ɣ a r | m a ʕ iː ʃ | sˤ a r aː f e t | m i j e"
69
+ },
70
+ {
71
+ "voice_time_seconds": {"start": 33.0, "end": 35.4},
72
+ "arabic": "لحظة أفحص...",
73
+ "hebrew_transliteration": "לַחְטַ'ה אַפְחַצ.",
74
+ "hebrew": "רגע, אבדוק.",
75
+ "expected_phonemes": "l a ħ ðˤ a | ʔ a f ħ a sˤ"
76
+ },
77
+ {
78
+ "voice_time_seconds": {"start": 35.4, "end": 39.2},
79
+ "arabic": "لا والله معيش، فيه بس ٢٠ إذا بفيدوك.",
80
+ "hebrew_transliteration": "לַא וַאללַה מַעִיש, פִיה בַּס עִשְרִין אִדַ'א בִּפִידַכּ.",
81
+ "hebrew": "לא, בחיי, אין לי; יש רק עשרים, אם זה עוזר לך.",
82
+ "expected_phonemes": "l aː | w aː l l a | m a ʕ iː ʃ | f iː h | b a s | ʕ i ʃ r iː n | ʔ i dˤ a | b i f iː d a k"
83
+ },
84
+ {
85
+ "voice_time_seconds": {"start": 39.2, "end": 42.8},
86
+ "arabic": "لا معلش، مش مهم، بدبّرها.",
87
+ "hebrew_transliteration": "לַא מַעְלֵיש, מֻש מֻהִם, בַּדַבֶּרְהַא.",
88
+ "hebrew": "לא נורא, לא חשוב, אסתדר.",
89
+ "expected_phonemes": "l aː | m a ʕ l eː ʃ | m u ʃ | m u h i m | b a d a b e r h a"
90
+ },
91
+ {
92
+ "voice_time_seconds": {"start": 42.8, "end": 44.6},
93
+ "arabic": "تفضّلي الباقي، معوّضين.",
94
+ "hebrew_transliteration": "תְפַדַّלִי אִלְ-בַּאקִי, מְעַוַّדִין.",
95
+ "hebrew": "הנה העודף, תבואי שוב.",
96
+ "expected_phonemes": "t f a d d a l i | i l b aː ʔ i | m ʕ a w w a d iː n"
97
+ },
98
+ {
99
+ "voice_time_seconds": {"start": 44.6, "end": 46.4},
100
+ "arabic": "تسلم، يعطيك العافية!",
101
+ "hebrew_transliteration": "תִסְלַם, יַעְטִיכּ אִלְ-עַאפְיֶה!",
102
+ "hebrew": "תודה, שיהיה לך כוח ובריאות!",
103
+ "expected_phonemes": "t i s l a m | j a ʕ tˤ iː k | i l ʕ aː f j e"
104
+ },
105
+ {
106
+ "voice_time_seconds": {"start": 46.4, "end": 48.4},
107
+ "arabic": "الله يعافيك، مع السلامة!",
108
+ "hebrew_transliteration": "אַללַה יְעַאפִיכּ, מַע אִלְ-סַלַאמֶה!",
109
+ "hebrew": "שאלוהים ייתן לך בריאות, להתראות!",
110
+ "expected_phonemes": "i l l a | j ʕ aː f iː k | m a ʕ | i l s a l aː m e"
111
+ }
112
+ ]
113
+ }
114
+ ]
docs/gop-pooling.md ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GOP frame pooling
2
+
3
+ How `score_tokens` ([app/align.py](../app/align.py)) turns a token window's frames into one target score and one
4
+ competitor score, and why short vowels need something other than the max. Benchmark:
5
+ `python scripts/gop_pooling_bench.py`. Tests: [tests/test_gop_pooling.py](../tests/test_gop_pooling.py).
6
+
7
+ ## Notation
8
+
9
+ - Window of token *i*: `W = [a, b)`, from the end of token *i−1* to the start of token *i+1*. `n = |W|`.
10
+ - `p_t(k)` is the posterior of column *k* at frame *t*, and `ℓ_t(k) = log p_t(k)`. *y* is the target.
11
+ - *C* is the set of competitors: every column except blank, the target, its two neighbours, and their accepted realisations.
12
+ - A pool `P(k)` maps the window to one posterior-like value per column. The score is:
13
+
14
+ ```
15
+ GOP = log P(y) − max(log P(y), max_{c∈C} log P(c)) ≤ 0
16
+ p̂ = max_t p_t(y) (always the max, whatever P is)
17
+ score = 100 · exp(λ · GOP) · sqrt(min(1, p̂ / κ)) λ = 0.5, κ = 0.30
18
+ ```
19
+
20
+ The change is the split between the two terms. **`ratio_pool` changes only `P`, the relative evidence. The
21
+ presence term `p̂` and the deletion test stay max-pooled.** The reason is below (result 1).
22
+
23
+ ## The pools
24
+
25
+ | `ratio_pool` | `P(k)` | Limits |
26
+ |---|---|---|
27
+ | `max` (old behaviour) | `max_t p_t(k)` | — |
28
+ | `mean` | `(1/n) Σ_t p_t(k)` | — |
29
+ | `topk` | `(1/K) Σ_{t ∈ top-K of k} p_t(k)` (each column uses its own best K frames) | K=1 → max, K=n → mean |
30
+ | `lse` | `(1/n Σ_t p_t(k)^{1/τ})^τ`, which is `τ·(LSE_t(ℓ_t(k)/τ) − log n)` | τ→0 → max, τ=1 → mean |
31
+ | `energy` | `Σ_t w_t p_t(k)`, with `w_t ∝ ẽ_t^γ` | γ=0 → mean |
32
+ | `gated` | mean over `{t : ẽ_t ≥ θ}` | θ=0 → mean |
33
+
34
+ `ẽ_t` is the frame's RMS, max-filtered over ±`energy_spread` frames and divided by the loudest frame in the
35
+ window. The token's own aligned span always gets `w = 1`: if the gate dropped the frame where the token was
36
+ emitted, the target would fall to ≈0 (3.7/100 in the benchmark without this guard).
37
+
38
+ All pools work in the **probability** domain. The literal alternative, a weighted mean of frame log-ratios
39
+ `Σ w_t (ℓ_t(y) − ℓ_t(c))`, breaks on CTC output. On blank frames both posteriors are ~1e-4, so their log-ratio
40
+ is noise of order ±5. A single frame where the target is at its floor adds −9 to the sum. Mixing in the
41
+ probability domain caps what any one frame can contribute at `w_t`.
42
+
43
+ ## Results
44
+
45
+ **1. A symmetric pool cannot tell two single-frame spikes apart.** If the target and the competitor each occupy
46
+ one frame, with heights `h_y` and `h_c`, every pool above gives `P(y)/P(c) = h_y/h_c`: the dilution factor
47
+ (`1/n`, `1/K`, `n^{−τ}`) is the same for both. A pool only helps when the two differ in **support** (how many
48
+ frames they cover) or in **location** (loud or quiet frames). A correct CTC vowel is itself a 1–2 frame spike,
49
+ so its *presence* has to be read with the max. That is why `p̂` is never pooled.
50
+
51
+ **2. The mass ratio does not depend on window length; the presence term does.** For `mean`:
52
+ `P(y)/P(c) = Σ p_t(y) / Σ p_t(c)`, so the `1/n` cancels. Unweighted averaging hurts only when the averaged
53
+ value also feeds the confidence. A 0.92 one-frame emission in a 10-frame window gives `P = 0.092`, so
54
+ `sqrt(0.092/0.30) = 0.55`: score 55, which is the "naive" column in the benchmark.
55
+
56
+ **3. Mass pooling accumulates haze.** A faint competitor at ε per frame adds up to `n·ε`, while the target stays one
57
+ peak. When a hesitation stretches the window, `mean` loses (a 0.05 schwa haze: 100 at 20 frames, 83 at 30, 63
58
+ at 50). `gated` and `energy` drop the silent frames. `topk` caps the competitor at its K best frames.
59
+
60
+ **4. Spike sensitivity.** For a correct /a/ with frames .2/.4/.35 and a one-frame /i/ spike of height *h* inside the window:
61
+
62
+ | h | max | mean / energy / gated | top2 | top3 | lse τ=.5 | lse τ=.25 |
63
+ |---|---|---|---|---|---|---|
64
+ | .5 | 89 | 100 | 100 | 100 | 100 | 95 |
65
+ | .9 | 67 | 100 | 91 | 100 | 79 | 71 |
66
+ | .98 | 64 | 98 | 88 | 98 | 76 | 68 |
67
+
68
+ LSE moves smoothly between max and mean. With r = 1/τ = 2 it still prefers one 0.9 frame to three moderate
69
+ ones, so it absorbs a tall spike only partly.
70
+
71
+ **5. Substitutions still fail.** Every pool scores the sustained /i/-for-/a/ case at about 20, and the one-frame
72
+ substitution at about 6. The one-frame substitution is also a single spike, but there the target is *absent*
73
+ (`p̂` ≈ .03), so both the ratio and the presence term are low. A pool only rescues a target that is clearly present.
74
+
75
+ **6. Separation.** Margin between the worst correct vowel and the best substitution in the synthetic set: max 46,
76
+ lse 59, top2 70, mean / energy / gated / top3 80. These are synthetic numbers. Use them to rank the pools, not to set thresholds.
77
+
78
+ ## Class-based windows
79
+
80
+ `compute_token_gop` reads each token from a window chosen by its class (`token_window`):
81
+
82
+ - **Consonants: wide**, `[end(i−1), start(i+1))`. When the alignment forces a path through a consonant that was
83
+ substituted, the sound actually said peaks in the blanks around the forced frame
84
+ (`consonant: competitor in blanks`: wide 15.8, competitor q; read narrow, q is lost).
85
+ - **Vowels: `GOP_VOWEL_WINDOW`** (`GopConfig.vowel_window`). `wide` (the default), or the aligned span ± pad
86
+ (`narrow` = ±1), clamped to the wide window. Only the **competitor ratio** uses the narrow window. Presence
87
+ (`peak`) and `deleted` are always read from the wide window, so a vowel aligned to a single frame is still found
88
+ deleted, and a strong competitor anywhere in the wide window still blocks a deletion (it is a substitution, not a
89
+ deletion).
90
+ - **Glides**: for a vowel next to j or w, the vowels that glide is made of (j: i iː e eː; w: u uː o oː;
91
+ `GLIDE_KIN` in scorer.py) do not count as competitors **on the frames between the vowel's span and the glide**,
92
+ for the ratio and for the deletion test. Inside the vowel's own span they still count, so /ji/ said for /ja/ fails.
93
+ This is always on and does not depend on the window setting.
94
+
95
+ CTC Viterbi gives every token at least one frame, so a span of 0 frames cannot happen; a deleted vowel gets one.
96
+
97
+ ### Why the vowel window defaults to wide
98
+
99
+ From `python scripts/gop_window_bench.py` (spans from `ctc_viterbi`, no audio):
100
+
101
+ | case | want | wide max | pad 1 max | wide mean |
102
+ |---|---|---|---|---|
103
+ | spike in transition | pass | 74.5 | 100 | 100 |
104
+ | displaced substitution (/a/ .35, /i/ .85 held 2 frames later) | fail | 64.2 | **100** | 46.1 |
105
+ | margin (worst pass − best fail) | | 10.3 | **0.0** | 53.9 |
106
+
107
+ The narrow window drops the transition spike by not looking at those frames, so it also stops seeing a real
108
+ vowel held outside span ± pad. Each pad goes blind beyond its reach: pad 0 at 1 frame away, pad 1 at 2, pad 2 at 3.
109
+ A pooled ratio over the wide window drops the same spike by its support (one frame against several), and still
110
+ sees the held substitution. A pooled ratio over a narrow window inherits the blind spot (pad1 mean: 100).
111
+ So the default is wide plus a pooled ratio. Use `narrow` when the lab shows vowel false flags whose competitor
112
+ sits in the transitions and pooling does not clear them.
113
+
114
+ The displaced case needs the target to be weakly present elsewhere. In a plain substitution the aligner usually
115
+ puts the target on the substituted frames, where every pad sees the competitor (`sustained substitution`: 21
116
+ everywhere).
117
+
118
+ ## Failure modes
119
+
120
+ - **Voiceless neighbours.** RMS measures loudness, not voicing. A vowel next to /s ʃ f/ shares its window
121
+ with sibilant frication, which can be as loud as the vowel. `energy` then puts weight on the fricative frames,
122
+ and a competitor spike there counts in full. A vowel before /t k q ʔ/ ends in a quiet devoiced tail; the gate
123
+ drops it, which is usually the intent. A voicing feature (low-band energy, or autocorrelation periodicity) would
124
+ be a better weight than RMS.
125
+ - **Pharyngeals and emphatics.** Next to ʕ ħ sˤ tˤ, /a/ backs to [ɑ], which is a *sustained* competitor across the
126
+ whole nucleus. That is coarticulation, but it looks like a real substitution to every pool. Pooling cannot fix it;
127
+ the near-vowel floor and the exemptions in `rules.py` handle it.
128
+ - **Fast speech.** Windows of 1–3 frames: `n == 1` returns the max; with small *n*, `topk` and `mean` agree, and
129
+ the energy nucleus may be a single frame that the ±1 spread blends with the consonants. All pools converge to
130
+ roughly the max, which is fine: there are no frames for a spike to hide in.
131
+ - **The spread cuts both ways.** `energy_spread` forgives a CTC emission one frame off the nucleus, but it also
132
+ lifts a competitor spike one frame off the nucleus over the gate. The span guard already protects the target,
133
+ so `energy_spread=0` rejects spikes better; keep 1 until real data shows CTC peaks landing outside the alignment.
134
+ - **Short-vowel deletion** (colloquial elision of unstressed /i/, /u/). `deleted` still reads the max peaks, so it
135
+ does not change. The ratio of a deleted vowel is target floor vs. whatever the consonants leak into the window. The gate keeps the
136
+ consonant frames because they are loud, so a deleted vowel should score about the same as before (not covered
137
+ by the synthetic set yet).
138
+ - **A real one-frame vowel that is in between** (target 0.4, competitor 0.9, both one frame) gives the same
139
+ ratio under every pool (result 1). It is irreducible: the model is unsure which vowel it heard.
140
+ - **Quiet consonants** (ʔ h ʕ). Energy pools would discard exactly the frames these live in, so
141
+ `pool_vowels_only` (default on) keeps the max for every consonant.
142
+ - **No audio.** `energy` and `gated` fall back to `mean` when `score_logp` gets no `energy` (for example a rescore
143
+ from cached posteriors), and inherit its haze problem. Use `topk` there, or pass the energy along.
144
+
145
+ ## Rollout
146
+
147
+ The pool comes from the env var `GOP_RATIO_POOL` (empty: `gated`; see `.env.example`). `max` reproduces the old
148
+ scores exactly (`test_max_pool_is_the_previous_scorer`). The lab shows the pool beside the version, since the version
149
+ hashes only the code.
150
+ Switching changes the GOP distribution for vowels, which moves toward 0, so everything tuned on it has to be refreshed:
151
+
152
+ 1. the margins in `rules` (`Scorer._margin`) and the native-reference baselines (`_baseline`): re-record the references;
153
+ 2. the calibration floors (`set_calibrations`): rerun `scripts/rescore.py` on the labelled set;
154
+ 3. the teacher trace still reports `p_target` and `p_competitor` as maxima, so with a pooled ratio a competitor
155
+ can show a higher peak than the target while the GOP is 0.
156
+
157
+ Suggested setting: `ratio_pool="gated"` (θ = 0.3, about −10 dB), `pool_vowels_only=True`, with `topk` K=3 as the
158
+ setting when there is no audio.
scripts/bench.py CHANGED
@@ -74,13 +74,12 @@ def main() -> int:
74
  return scorer.score(decode_audio(audio), phrase, refs.get(key, phrase) if refs else [])
75
  new: dict[int, dict] = {}
76
  for k, s in enumerate(samples, 1):
77
- item = lab.BY_ID.get(s["item"])
78
- if item is None: # an item since dropped from the list
79
  continue
80
  try:
81
  found = auth.lab_audio(s["id"])
82
  if found:
83
- phrase = lab.phrase(item)
84
  new[s["id"]] = score(found[0], phrase, phrase["key"])
85
  except (AuthUnavailable, ValueError) as e:
86
  print(f"sample {s['id']}: skipped ({e})", file=sys.stderr)
 
74
  return scorer.score(decode_audio(audio), phrase, refs.get(key, phrase) if refs else [])
75
  new: dict[int, dict] = {}
76
  for k, s in enumerate(samples, 1):
77
+ phrase = lab.sample_phrase(s)
78
+ if phrase is None: # a legacy row without enough information to reconstruct its phrase
79
  continue
80
  try:
81
  found = auth.lab_audio(s["id"])
82
  if found:
 
83
  new[s["id"]] = score(found[0], phrase, phrase["key"])
84
  except (AuthUnavailable, ValueError) as e:
85
  print(f"sample {s['id']}: skipped ({e})", file=sys.stderr)
scripts/gop_pooling_bench.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare the GOP frame pools (GopConfig.ratio_pool) on the synthetic CTC scenarios of tests/test_gop_pooling.py.
2
+
3
+ python scripts/gop_pooling_bench.py
4
+
5
+ Prints the /a/ score per scenario and pool, the margin between the worst correct vowel and the best
6
+ substitution (higher separates better), then two sweeps: a spurious spike's height, and a faint
7
+ competitor haze over a growing silent window.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from dataclasses import replace
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
18
+
19
+ from app.align import Alignment, GopConfig, score_tokens # noqa: E402
20
+ from tests.test_gop_pooling import A, BLANK, CONFIGS, I, K, SCENARIOS, SCHWA, T_, TOKENS, V, emissions, \
21
+ naive_mean_score, score_a # noqa: E402
22
+
23
+ CONFIGS = {**CONFIGS, "lse.25": GopConfig(ratio_pool="lse", pool_tau=0.25),
24
+ "gated0": GopConfig(ratio_pool="gated", energy_spread=0)}
25
+
26
+
27
+ def table() -> None:
28
+ names = list(CONFIGS)
29
+ print(f"{'scenario':24}{'want':>6}" + "".join(f"{n:>8}" for n in names) + f"{'naive':>8}")
30
+ for sc in SCENARIOS:
31
+ row = [score_a(sc, cfg).score for cfg in CONFIGS.values()]
32
+ print(f"{sc.name:24}{'pass' if sc.good else 'fail':>6}" + "".join(f"{x:8.1f}" for x in row)
33
+ + f"{naive_mean_score(sc):8.1f}")
34
+ margin = []
35
+ for cfg in CONFIGS.values():
36
+ good = [score_a(s, cfg).score for s in SCENARIOS if s.good]
37
+ bad = [score_a(s, cfg).score for s in SCENARIOS if not s.good]
38
+ margin.append(min(good) - max(bad))
39
+ print(f"{'margin (min ok - max bad)':30}" + "".join(f"{x:8.1f}" for x in margin))
40
+
41
+
42
+ def spike_sweep() -> None:
43
+ print("\nspurious /i/ spike in the nucleus vs a correct /a/ of .2/.4/.35")
44
+ print(f"{'spike':>6}" + "".join(f"{n:>8}" for n in CONFIGS))
45
+ for h in (.3, .5, .7, .9, .98):
46
+ sc = replace(SCENARIOS[1], logp=emissions({4: {A: .2}, 5: {A: .4}, 6: {A: .35}, 8: {I: h}}))
47
+ print(f"{h:6.2f}" + "".join(f"{score_a(sc, cfg).score:8.1f}" for cfg in CONFIGS.values()))
48
+
49
+
50
+ def haze_sweep() -> None:
51
+ print("\none-frame /a/ at .9, a .05 schwa haze, a silent pause growing the window")
52
+ print(f"{'frames':>6}" + "".join(f"{n:>8}" for n in CONFIGS))
53
+ for n in (10, 15, 20, 30, 50):
54
+ p = np.full((n, V), 1e-4)
55
+ p[:, SCHWA] = .05
56
+ p[1, K], p[5, A], p[n - 2, T_] = .9, .9, .9
57
+ p[:, BLANK] = 0
58
+ p[:, BLANK] = 1 - p.sum(1)
59
+ energy = np.r_[[.3, .6, .5], np.ones(4), np.full(n - 9, .02), [.5, .1]]
60
+ al = Alignment(0.0, [(1, 2), (5, 6), (n - 2, n - 1)])
61
+ row = [score_tokens(np.log(p), al, TOKENS, BLANK, cfg, energy=energy)[1].score for cfg in CONFIGS.values()]
62
+ print(f"{n:6d}" + "".join(f"{x:8.1f}" for x in row))
63
+
64
+
65
+ if __name__ == "__main__":
66
+ table()
67
+ spike_sweep()
68
+ haze_sweep()
scripts/gop_window_bench.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare GOP windows (GopConfig.vowel_window) and pools on the synthetic cases of tests/test_gop_window.py.
2
+
3
+ python scripts/gop_window_bench.py
4
+
5
+ The spans come from ctc_viterbi. The cases have no audio, so "gated" falls back to the plain mean here
6
+ (scripts/gop_pooling_bench.py measures the energy pools). Prints the score of the token under test per case and
7
+ setting, and the margin between the worst token said right and the best one said wrong (higher separates better);
8
+ then how far a substituted vowel can sit from the aligned span before each pad stops seeing it.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
16
+
17
+ from app.align import GopConfig # noqa: E402
18
+ from tests.test_gop_window import A, CASES, I, K, T_, Case, score # noqa: E402
19
+
20
+ SETTINGS = {
21
+ "wide max": GopConfig(ratio_pool="max", vowel_window=None),
22
+ "pad0 max": GopConfig(ratio_pool="max", vowel_window=0),
23
+ "pad1 max": GopConfig(ratio_pool="max", vowel_window=1),
24
+ "pad2 max": GopConfig(ratio_pool="max", vowel_window=2),
25
+ "wide mean": GopConfig(ratio_pool="mean", vowel_window=None),
26
+ "pad1 mean": GopConfig(ratio_pool="mean", vowel_window=1),
27
+ }
28
+
29
+
30
+ def table() -> None:
31
+ print(f"{'case':34}{'want':>6}" + "".join(f"{n:>11}" for n in SETTINGS))
32
+ for c in CASES:
33
+ print(f"{c.name:34}{'pass' if c.good else 'fail':>6}"
34
+ + "".join(f"{score(c, cfg).score:11.1f}" for cfg in SETTINGS.values()))
35
+ margins = []
36
+ for cfg in SETTINGS.values():
37
+ good = [score(c, cfg).score for c in CASES if c.good]
38
+ bad = [score(c, cfg).score for c in CASES if not c.good]
39
+ margins.append(min(good) - max(bad))
40
+ print(f"{'margin (min ok - max bad)':40}" + "".join(f"{m:11.1f}" for m in margins))
41
+
42
+
43
+ def distance_sweep() -> None:
44
+ print("\n/a/ at .35 on frame 4, /i/ held two frames at .85, d frames after the /a/")
45
+ print(f"{'d':>4}" + "".join(f"{n:>11}" for n in SETTINGS))
46
+ for d in range(1, 7):
47
+ c = Case("", [K, A, T_], {1: {K: .9}, 4: {A: .35}, 4 + d: {I: .85}, 5 + d: {I: .85}, 13: {T_: .9}}, 1, False, n=15)
48
+ print(f"{d:4d}" + "".join(f"{score(c, cfg).score:11.1f}" for cfg in SETTINGS.values()))
49
+
50
+
51
+ if __name__ == "__main__":
52
+ table()
53
+ distance_sweep()
scripts/import_minerva.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Import the three-video Minerva pilot into the lab as native, correctly spoken samples.
3
+
4
+ The videos have no subtitle stream. PIL/Tesseract are deliberately not dependencies: the reviewed, pointed-Hebrew
5
+ captions and the detected time spans are the pilot manifest below. ``app.texts.text_phrases`` remains the single
6
+ source of Arabic spelling and IPA, exactly as it is for teacher-authored texts.
7
+
8
+ Usage:
9
+ python scripts/import_minerva.py --dry-run
10
+ python scripts/import_minerva.py --email teacher@example.com
11
+
12
+ Existing ``minerva:<video-id>:<line>`` rows are skipped, making an interrupted import safe to resume.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import uuid
21
+ from dataclasses import dataclass
22
+ from datetime import date
23
+ from pathlib import Path
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
26
+
27
+ from dotenv import load_dotenv # noqa: E402
28
+
29
+ from app import lab # noqa: E402
30
+ from app.auth import AuthUnavailable, load_auth # noqa: E402
31
+ from app.main import MODEL_ID, build_version # noqa: E402
32
+ from app.reference import References # noqa: E402
33
+ from app.scorer import decode_audio, load_scorer # noqa: E402
34
+ from app.texts import text_phrases # noqa: E402
35
+ from app.tts import VOICES, load_tts # noqa: E402
36
+
37
+ SPEAKER = "מינרווה"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Video:
42
+ youtube_id: str
43
+ title: str
44
+ lines: tuple[tuple[float, float, str], ...]
45
+
46
+
47
+ # Caption changes were measured at 5 fps. The first story line is its spoken title, before the first full caption.
48
+ PILOT = (
49
+ Video("U8aBRd3-wlM", "במכולת", (
50
+ (9.0, 10.8, "פִיה עִנְדַכּ חַלִיב טַאזֶה?"),
51
+ (10.8, 12.8, "אַה, גֻ'וַּא פִי ת-תַּלַּאגֶ'ה, עַלַא אִידֶכּ לְ-יַמִין."),
52
+ (12.8, 14.2, "מֻמְתַאז."),
53
+ (14.2, 17.6, "מֻמְכּן תְחֻטְלִי כַּמַאן וַקִיַּת גֻ'בְּנֶה צַפְרַא?"),
54
+ (17.6, 21.8, "אַה טַבְּעַן, בַּס תְוַאנִי אַחְ'לַצ מַעַאה לְ-חִסַאב."),
55
+ (21.8, 24.6, "תְפַדַّל. שֻכְּרַן!"),
56
+ (24.6, 27.6, "הַאדַא הֻוֶּ? אַיוַה, קַדֵּיש לְ-חִסַאב?"),
57
+ (27.6, 30.4, "חַ'מְסֶה וְעִשְרִין שֵיקֶל. תְפַדַّל."),
58
+ (30.4, 33.0, "מַעְכִּיש אַצְעַ'ר? מַעִיש צַרַאפֶת מִיֶּה."),
59
+ (33.0, 35.4, "לַחְטַ'ה אַפְחַצ."),
60
+ (35.4, 39.2, "לַא וַאללַה מַעִיש, פִיה בַּס עִשְרִין אִדַ'א בִּפִידַכּ."),
61
+ (39.2, 42.8, "לַא מַעְלֵיש, מֻש מֻהִם, בַּדַבֶּרְהַא."),
62
+ (42.8, 44.6, "תְפַדַّלִי אִלְ-בַּאקִי, מְעַוַّדִין."),
63
+ (44.6, 46.4, "תִסְלַם, יַעְטִיכּ אִלְ-עַאפְיֶה!"),
64
+ (46.4, 48.4, "אַללַה יְעַאפִיכּ, מַע אִלְ-סַלַאמֶה!"),
65
+ )),
66
+ Video("OuAwxXUZBU4", "אסיפת הורים", (
67
+ (8.8, 11.8, "תַמַאם, כִּיפֶכּ? שוּ צַאר מַעֶכּ?"),
68
+ (11.8, 14.2, "בְּסֵידֶר, אִסַּא טִלְעֵת מִן אִלְ-מַדְרַסֶה."),
69
+ (14.2, 17.4, "קַעַדֶת מַע אִלְ-מֻרַבִּיֶה וּבַּאקִי לְ-מֻעַלִּמַאת."),
70
+ (17.4, 21.2, "מִיס נַוַאל בְּתְקוּל אִנּוּ רַבִּיע וַלַד בִּגַ'נֵּן וְעַקְלוֹ כְּבִּיר."),
71
+ (21.2, 24.4, "מֻאַדַّבּ, בְּחִבּ יְסַאעֶד עֵ'ירוֹ, שַאטֶר וּפַתֶח."),
72
+ (24.4, 25.8, "בַּס מִתְל אִלְ-עַאדֶה."),
73
+ (25.8, 29.4, "לַאזֶם יִשְתַעֵ'ל אַכְּתַר עַלַא מַוְדוּע אִלְ-תַרְכִּיז."),
74
+ (29.4, 31.0, "כְּתִיר בִּשַתּ וּבִּסְרַח."),
75
+ (31.0, 35.8, "וְהַאדַא בִּחַלִּיה יַעְמַל אַעְ'לַאט זְעִ'ירֶה פִי דִּרַאסֶה."),
76
+ (35.8, 37.4, "הֵי וּבַּאקִי לְ-מֻעַלִּמַאת מִנְתַבְּהַאת."),
77
+ (37.4, 39.2, "אִנּוּ מֻשְכִּלְתוֹ מֻש עַדַם פַהֶם."),
78
+ (39.2, 41.0, "בִּלְ-עַכֶּס, עַרְבִּיהֶן שַאטֶר כְּתִיר."),
79
+ (41.0, 46.0, "בַּס מֻשְכִּלְתוֹ קִלֶּת תַרְכִּיז."),
80
+ (46.0, 49.4, "אִדַ'א הֵיכּ אִלְ-וַדְע פִש עַלַא שוּ נִקְלַק."),
81
+ (49.4, 52.6, "הֻוֶּ מְתַחַסֵּן כְּתִיר וּמֻש לַאזֶם נְדַקֵּק עַלַא כֻּל אִשִי."),
82
+ (52.6, 56.4, "רַאח יִתְעַ'יַּר מַע אִלְ-וַקְת וִלְ-גִ'יל אִלוֹ חַקּוֹ."),
83
+ (56.4, 59.8, "מַדְבּוּט. אַנַא בַּקוּל לַאזֶם נְגִ'יבְלוֹ הַדִיֶּה זְעִ'ירֶה."),
84
+ (59.8, 61.6, "מִתְל מַא וַעַדְנַאה."),
85
+ (61.6, 64.2, "אַה אַכִּיד, אִלְ-יוֹם מִנְטַלַע כֻּלְנַא מִשְוַאר."),
86
+ (64.2, 65.2, "וּמְנְגִ'יבְלוֹ שַעְ'לֶה."),
87
+ (65.2, 69.2, "חַ'לִּיה יְחִס אִנּוּ אִחְנַא מְקַדְּרִין תַעַבּוֹ וְגִ'דִּיֶּתוֹ."),
88
+ (69.2, 74.2, "טַיֵּב מֻמְתַאז, בְּלַא מַנְחַכִּי, בַּאי."),
89
+ )),
90
+ Video("V2y6LqnoXTo", "הקדרה", (
91
+ # The title is spoken over the intro card; 5.6-6.4 is the silent transition into the first story caption.
92
+ (0.4, 5.6, "אִלְ-טֻנְגַ'רַה וִלְדַת טֻנְגַ'רַה."),
93
+ (6.4, 10.2, "מַרַּה טַלַבּ גַ'מַא מִן גַ'ארוֹ יַעְטִיה טֻנְגַ'רַה כְּבִּירֶה."),
94
+ (10.2, 14.2, "עַשַאן יִקְדַר יִטְבַּח וְיִטְעַמִּי מַעַאזִימוֹ לְ-כֻּתַאר."),
95
+ (14.2, 17.2, "אִלִּי גַ'איִין עִנְדוֹ עַלַא אִלְ-עַ'דַא."),
96
+ (17.2, 21.0, "אִלְ-גַ'אר וַאפַק וְאַעְטַאה טֻנְגַ'רַה לְגַ'מַא."),
97
+ (21.0, 25.0, "בַּעְד אַכַּם יוֹם רַגַ'ע גַ'מַא אִלְ-טֻנְגַ'רַה לְ-כְּבִּירֶה."),
98
+ (25.0, 28.6, "וּבְּקַלְבְּהַא טֻנְגַ'רַה זְעִ'ירוֹנֶה."),
99
+ (28.6, 31.8, "סַאַל אִלְ-גַ'אר עַן הַאי טֻנְגַ'רַה לְ-זְעִ'ירֶה."),
100
+ (31.8, 36.2, "וְגַ'מַא פַסַּרְלוֹ אִנְהַא אִנְוַלַדַת מִן אִלְ-טֻנְגַ'רַה לְ-כְּבִּירֶה."),
101
+ (36.2, 40.4, "פִי לְ-פַתְרַה אִלִּי כַּאנֶת אִלְ-טֻנְגַ'רַה לְ-כְּבִּירֶה עִנְדוֹ."),
102
+ (40.4, 42.2, "קִבֵּל אִלְ-גַ'אר מִן גַ'מַא לְ-עַ'בִּי."),
103
+ (42.2, 45.8, "הַאי לְ-בִּנְת לְ-זְעִ'ירֶה בְּכֻל פַרַח וּסֻרוּר."),
104
+ (45.8, 48.4, "וּפִי קַלְבּוֹ תְחַוַּת עַלַא גַ'מַא."),
105
+ (48.4, 51.2, "אִנּוּ וַאחַד אַהְבַּל מַעְתוֹה."),
106
+ (51.2, 54.2, "בַּעְד אַכַּם יוֹם אַגַ'א גַ'מַא עִנְד גַ'ארוֹ."),
107
+ (54.2, 58.4, "יִטְלֻבּ מִנּוֹ יִמְסִכּוֹ אִלְ-טֻנְגַ'רַה כַּמַאן מַרַּה."),
108
+ (58.4, 61.0, "פְרִח אִלְ-גַ'אר וְקַאל פִי קַלְבּוֹ."),
109
+ (61.0, 64.0, "הַלַק בְּתוּלַד אִלְ-טֻנְגַ'רַה כַּמַאן בִּנְת."),
110
+ (64.0, 68.0, "וּמִין בִּעְרַף, בַּלְכִּי הַאי לְ-מַרַּה וַלַדַת תוֹאַם."),
111
+ (68.0, 72.6, "וְהֵיכּ מַסַכּ אִלְ-גַ'אר טֻנְגַ'רְתוֹ לְגַ'מַא."),
112
+ (72.6, 76.4, "מַרְקַת עִדֶּת אַיַּאם וְגַ'מַא מַא רַגַ'עְש אִלְ-טֻנְגַ'רַה."),
113
+ (76.4, 79.4, "עַשַאן הֵיכּ רַאח אִלְ-גַ'אר לְעִנְד גַ'מַא."),
114
+ (79.4, 81.8, "וְטַלַבּ מִנּוֹ יִרַגְ'עְהַא."),
115
+ (81.8, 85.6, "בַּס גַ'מַא קַאלְלוֹ אִנּוּ אִלְ-טֻנְגַ'רַה מַאתַת."),
116
+ (85.6, 88.0, "אִלְ-גַ'אר תְעַצַּב כְּתִיר וְקַאל."),
117
+ (88.0, 90.6, "כִּיף בְּתִסְמַח לְנַפְסַכּ תִתְחַוַּת עַלַיّ?"),
118
+ (90.6, 93.6, "שוּ? אִלְ-טֻנְגַ'רַה כַּאיְנֶה כַּאִן חַיّ?"),
119
+ (93.6, 95.8, "בְּתְעִיש וּבְּתְמוּת?"),
120
+ (95.8, 97.8, "גַ'מַא רַד עַלֵיה וְקַאל."),
121
+ (97.8, 101.0, "לַא סַמַח אַללַה, אִתְחַוַּת עַלַא שַחְ'צ מִתְלַכּ."),
122
+ (101.0, 104.4, "בַּס אִדַ'א מֻמְכִּן אִלְ-טֻנְגַ'רַה תוּלַד."),
123
+ (104.4, 106.4, "מַעְנַאה מֻמְכִּן אִנְהַא תְמוּת."),
124
+ (106.4, 113.8, "וְהַאדִי נִהַאיַת אִלְ-קִצַּה."),
125
+ )),
126
+ )
127
+
128
+
129
+ def source_file(folder: Path, youtube_id: str) -> Path:
130
+ found = sorted(folder.glob(f"*{youtube_id}*"))
131
+ if len(found) != 1:
132
+ raise ValueError(f"expected one video containing {youtube_id}, found {len(found)}")
133
+ return found[0]
134
+
135
+
136
+ def wav_segment(video: Path, start: float, end: float) -> bytes:
137
+ command = ["ffmpeg", "-v", "error", "-ss", str(start), "-to", str(end), "-i", str(video),
138
+ "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-f", "wav", "-"]
139
+ done = subprocess.run(command, stdout=subprocess.PIPE, check=False)
140
+ if done.returncode or not done.stdout:
141
+ raise RuntimeError(f"ffmpeg could not extract {video.name} at {start:.1f}-{end:.1f}")
142
+ return done.stdout
143
+
144
+
145
+ def phrases() -> tuple[list[tuple[Video, int, float, float, dict]], list[str]]:
146
+ out, guessed = [], []
147
+ for video in PILOT:
148
+ for number, (start, end, tr) in enumerate(video.lines, 1):
149
+ made, new = text_phrases(f"minerva:{video.youtube_id}", [[f"{video.title} {number}", tr]])
150
+ phrase = made[0]
151
+ phrase["key"] = f"minerva:{video.youtube_id}:{number:02d}"
152
+ out.append((video, number, start, end, phrase))
153
+ guessed.extend(x for x in new if x not in guessed)
154
+ return out, guessed
155
+
156
+
157
+ def default_email() -> str:
158
+ raw = os.environ.get("MINERVA_IMPORT_EMAIL") or os.environ.get("TEACHER_EMAILS", "")
159
+ return raw.replace(",", " ").split()[0].lower() if raw.split() else ""
160
+
161
+
162
+ def main() -> int:
163
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
164
+ ap.add_argument("--videos-dir", type=Path, default=Path("/home/eyalshw/Videos/minerva"))
165
+ ap.add_argument("--email", default="", help="teacher email recorded as the importer (or MINERVA_IMPORT_EMAIL)")
166
+ ap.add_argument("--dry-run", action="store_true", help="validate videos, segments and transcripts; do not score/upload")
167
+ ap.add_argument("--verify", action="store_true", help="verify the 70 remote rows and audio names; do not score/upload")
168
+ ap.add_argument("--no-baseline", action="store_true", help="score without the native TTS baseline")
169
+ args = ap.parse_args()
170
+ load_dotenv()
171
+
172
+ entries, guessed = phrases()
173
+ if [len(v.lines) for v in PILOT] != [15, 22, 33] or len(entries) != 70:
174
+ raise RuntimeError("the Minerva pilot must stay exactly 15 + 22 + 33 = 70 lines")
175
+ files = {v.youtube_id: source_file(args.videos_dir, v.youtube_id) for v in PILOT}
176
+ for video, number, start, end, phrase in entries:
177
+ if end <= start or not phrase["variants"]:
178
+ raise ValueError(f"invalid line {video.youtube_id}:{number}")
179
+ print(f"validated {len(entries)} lines in {len(files)} videos")
180
+ if guessed and args.dry_run:
181
+ print(f"words read by rule ({len(guessed)}): {' '.join(guessed)}")
182
+ if args.dry_run:
183
+ return 0
184
+
185
+ email = (args.email or default_email()).strip().lower()
186
+ if not email:
187
+ print("give --email or set MINERVA_IMPORT_EMAIL/TEACHER_EMAILS", file=sys.stderr)
188
+ return 2
189
+ auth = load_auth()
190
+ if auth is None or not auth.secret:
191
+ print("needs SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY", file=sys.stderr)
192
+ return 2
193
+ try:
194
+ remote = [s for s in auth.lab_samples() if (s.get("speaker") or "") == SPEAKER]
195
+ existing = {s["item"] for s in remote}
196
+ except AuthUnavailable as e:
197
+ print(f"lab is unavailable: {e}", file=sys.stderr)
198
+ return 2
199
+ if args.verify:
200
+ expected = {phrase["key"] for _, _, _, _, phrase in entries}
201
+ audio = sum(bool(s.get("audio")) for s in remote if s.get("item") in expected)
202
+ missing = sorted(expected - existing)
203
+ extra = sorted(existing - expected)
204
+ print(f"remote Minerva pilot: {len(expected & existing)}/70 rows, {audio}/70 audio files named")
205
+ if missing:
206
+ print(f"missing: {' '.join(missing)}")
207
+ if extra:
208
+ print(f"other Minerva rows: {' '.join(extra)}")
209
+ return 0 if not missing and audio == 70 else 1
210
+
211
+ model = os.environ.get("MODEL_ID", MODEL_ID)
212
+ scorer = load_scorer(model)
213
+ tts = None if args.no_baseline else load_tts()
214
+ refs = References(scorer, tts, list(VOICES.values())) if tts else None
215
+ saved = skipped = 0
216
+ for n, (video, number, start, end, phrase) in enumerate(entries, 1):
217
+ item = phrase["key"]
218
+ if item in existing:
219
+ skipped += 1
220
+ print(f"[{n:02d}/70] skip {item}")
221
+ continue
222
+ data = wav_segment(files[video.youtube_id], start, end)
223
+ wav = decode_audio(data)
224
+ baseline = refs.get(item, phrase) if refs else []
225
+ result = scorer.score(wav, phrase, baseline, diagnostics=True)
226
+ if result.get("error"):
227
+ raise RuntimeError(f"{item}: scoring failed: {result['error']}")
228
+ row = {"email": email, "item": item, "ar": result["variant"]["ar"], "ipa": result["variant"]["ipa"],
229
+ "speaker": SPEAKER, "label": "correct", "mistake": None,
230
+ "device": f"minerva:{video.youtube_id}:{start:.1f}-{end:.1f}", "score": result["score"],
231
+ "heard_ipa": result["heard_ipa"], "flagged": lab.flagged_sounds(result), "result": result,
232
+ "model": model, "version": build_version()}
233
+ name = f"{date.today():%Y-%m}/minerva-{video.youtube_id}-{number:02d}-{uuid.uuid4().hex}.wav"
234
+ if not auth.add_lab_sample(row, data, "audio/wav", name):
235
+ raise RuntimeError(f"{item}: row saved but audio upload failed; stop before creating more incomplete rows")
236
+ saved += 1
237
+ print(f"[{n:02d}/70] saved {item}: {result['score']}")
238
+ print(f"Minerva pilot complete: {saved} saved, {skipped} already present")
239
+ return 0
240
+
241
+
242
+ if __name__ == "__main__":
243
+ raise SystemExit(main())
static/index.html CHANGED
@@ -456,6 +456,7 @@ body.driving .mic::before{display:none} body.driving .mic-zone.two{display:flex}
456
  #denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
457
  /* The teacher's panel (its parts are styled further down: the class tabs, the figures, the lessons, the students). */
458
  body:not(.manage) #manage,body.lab #manage,body:not(.lab) #lab,body.manage #wrap>header,body.manage #path,body.manage #today,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
 
459
  .mtitle{font:400 24px/1 var(--f-display);margin:0}
460
  #mState.setup{padding:10px 12px;border:1px solid var(--sumac);border-radius:10px;color:var(--ink)}
461
  #mState.setup code{display:block;margin-top:6px;font-size:12px;overflow-wrap:anywhere;user-select:all}
@@ -469,7 +470,7 @@ body:not(.manage) #manage,body.lab #manage,body:not(.lab) #lab,body.manage #wrap
469
  .build-info code{direction:ltr;unicode-bidi:isolate;user-select:all}
470
  #manage:not(.flags) #mBuild{display:none}
471
  /* The lab (מעבדה): the diagnostic list by group, each item a button with its count of recordings, and the item on show. */
472
- #lab{display:flex;flex-direction:column;gap:12px;width:100%;min-width:0;box-sizing:border-box}
473
  #lab>*,.lab-item>*{min-width:0}
474
  #labResult .stage-table-wrap{overflow-x:auto}
475
  .lab-who{flex-direction:row;flex-wrap:wrap;align-items:center;gap:10px 18px}
@@ -501,13 +502,43 @@ body:not(.manage) #manage,body.lab #manage,body:not(.lab) #lab,body.manage #wrap
501
  .lab-table th,.lab-table td{padding:5px 8px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}
502
  .lab-table th{color:var(--muted);font-weight:600;font-size:12px}
503
  .lab-table .ipa{direction:ltr;unicode-bidi:isolate;font-family:ui-monospace,monospace}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
  .lab-table tr.focus td:first-child{font-weight:700}
505
  .lab-table td.ok{color:var(--ok)} .lab-table td.mid{color:var(--amber)} .lab-table td.bad{color:var(--sumac);font-weight:700}
506
  .lab-table td.hit{background:var(--ok-bg)} .lab-table td.miss{background:var(--sumac-bg)}
 
 
 
 
 
 
 
 
 
 
 
507
  .lab-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
508
- #labModes{grid-template-columns:repeat(4,1fr)}
509
- #lab:not(.test) #labTest,#lab:not(.record) #labRecord,#lab:not(.flags) #labFlags,#lab:not(.results) #labResults{display:none}
510
- #labTest,#labRecord,#labFlags,#labResults{display:flex;flex-direction:column;gap:12px;min-width:0}
511
  #labList,#labFlagList,#wzList{display:flex;flex-direction:column;gap:10px}
512
  #wzSpeaker{min-width:0;width:12em;padding:6px 10px;border:1px solid var(--line);border-radius:10px;font:inherit;background:var(--surface);color:inherit}
513
  .wz-bar{flex-basis:100%;height:6px;border-radius:3px;background:var(--line);overflow:hidden}
@@ -1223,7 +1254,7 @@ body.quiet :is(#mic,#playMine,#driveBtn,.phone-btn),body.driving :is(#quietBtn,#
1223
  <div class="roundbar"><h1 class="mtitle" id="labTitle">מעבדה</h1><span class="sep"></span><button class="btn" id="labManage">ניהול</button><button class="btn primary" id="labPractice">לתרגול</button></div>
1224
  <p class="sub">רשימה קבועה של הברות ומילים קצרות, שכל אחת בודקת הגה אחד. כל דובר מקריא אותה פעם אחת. אומרים נכון, או בוחרים מראש את ההגה שבו תיעשה טעות מכוונת. כל הקלטה נשמרת עם התווית שלה, ומהן נמדד זיהוי הפונמות: כמה פעמים הגה שנאמר נכון סומן כטעות, וכמה טעויות מכוונות לא נתפסו.</p>
1225
  <div class="sub" id="labState" aria-live="polite"></div>
1226
- <div class="modes" id="labModes" role="tablist" aria-label="מעבדה"><button role="tab" data-mode="test">מבחן<small>הקלטת כל הרשימה</small></button><button role="tab" data-mode="record">הקלטה<small>פריט בודד, עם טעויות</small></button><button role="tab" data-mode="flags">דיווחים<small>הרצה מחדש של דיווחי ציון</small></button><button role="tab" data-mode="results">תוצאות<small>זיהוי לפי הגה</small></button></div>
1227
  <div id="labTest">
1228
  <div class="card wz-head">
1229
  <p class="sub">כל שורה היא פריט לומר. השורה המסומנת היא הבאה בתור: לוחצים 🎤, אומרים את הפריט נכון ככל האפשר, וההקלטה מושמעת לבד. אחרי ההשמעה: ✓ שולח אותה ועובר לשורה הבאה, ✗ מוחק אותה ומקליטים שוב. מה שנשלח נשמר כ"נאמר נכון" של הדובר, והוא הבסיס לבדיקת זיהוי הפונמות.</p>
@@ -1263,16 +1294,35 @@ body.quiet :is(#mic,#playMine,#driveBtn,.phone-btn),body.driving :is(#quietBtn,#
1263
  </div>
1264
  <div id="labFlagList"></div>
1265
  </div>
 
 
 
 
 
 
 
 
1266
  <div id="labResults">
1267
  <div class="card">
1268
- <div class="lab-actions"><label>דובר <select id="labWho"><option value="">כולם</option></select></label><button class="btn primary" id="labRerunAll">הרצה מחדש עם הגרסה הנוכחית</button><button class="btn" id="labSaveBase" hidden>שמירה כנקודת השוואה</button></div>
1269
  <div class="lab-actions"><span class="sub" id="labResState" aria-live="polite"></span></div>
 
1270
  <div class="lab-actions"><span class="sub" id="labBase"></span><button class="btn" id="labDropBase" hidden>מחיקה</button></div>
1271
  <p class="sub" id="labCmp"></p>
1272
  <div class="lab-tiles" id="labTiles"></div>
1273
  <p class="sub">איך בודקים שינוי במימוש: הריצו מחדש ושמרו כנקודת השוואה, שנו את המימוש, והריצו מחדש. ירוק: השתפר, אדום: הורע. שיפור אמיתי מוריד את "סומנו בטעות" בלי להוריד את "זיהוי". "סומן בטעות": הגה שנאמר נכון וסומן כטעות. "זיהוי": טעות מכוונת שסומנה. "דיווחים": ההגה סומן בטעות בדיווח מאושר מתרגול אמיתי. אותה בדיקה משורת הפקודה: <code>python scripts/bench.py</code></p>
1274
  </div>
1275
  <div class="card"><div class="stage-table-wrap"><table class="lab-table" id="labBench"></table></div></div>
 
 
 
 
 
 
 
 
 
 
1276
  </div>
1277
  </section>
1278
  </div>
@@ -4804,12 +4854,13 @@ async function showLab(){
4804
  labMode(store.get("ihki.labMode","test"));
4805
  }
4806
  function labMode(mode){
4807
- if(!["test","record","flags","results"].includes(mode)) mode="test";
4808
  store.set("ihki.labMode",mode);
4809
  for(const b of $("labModes").children) b.setAttribute("aria-selected",b.dataset.mode===mode?"true":"false");
4810
- for(const m of ["test","record","flags","results"]) $("lab").classList.toggle(m,m===mode);
4811
  if(mode==="test"){ drawWizard(); loadTtsVoices(); }
4812
  if(mode==="flags"&&!labFlags) loadLabFlags();
 
4813
  if(mode==="results") loadLabResults();
4814
  }
4815
  for(const b of $("labModes").children) b.onclick=()=>labMode(b.dataset.mode);
@@ -5138,12 +5189,80 @@ $("labRerun").onclick=async()=>{
5138
  btn.disabled=false; drawLabFlags();
5139
  };
5140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5141
  /* The lab's results: per sound, of the recordings as they were scored (see lab.benchmark in app/lab.py). */
5142
  /* The lab's results: per sound (see lab.benchmark in app/lab.py). As loaded, the recordings as they were scored at
5143
  the time; "הרצה מחדש" rescores them all with the version the server runs now, and compares that with a saved run
5144
  (the comparison point, kept on this device) or, without one, with the stored scores of the same recordings. */
5145
  let labRes=null, labNow=null; // the stored benchmark (with the recordings' ids), and the latest rescore's
5146
  const labBaseKey=()=>"ihki.labBase:"+$("labWho").value; // a comparison point for each speaker filter
 
 
5147
  async function loadLabResults(){
5148
  const who=$("labWho").value; $("labResState").textContent="טוען…"; labNow=null;
5149
  try{ const res=await api("/api/lab/results"+(who?`?speaker=${encodeURIComponent(who)}`:"")); labRes=await res.json().catch(()=>({}));
@@ -5152,9 +5271,32 @@ async function loadLabResults(){
5152
  const sel=$("labWho"); sel.replaceChildren(new Option("כולם",""));
5153
  for(const s of labRes.speakers) sel.append(new Option(s||"(בלי שם)",s)); sel.value=who;
5154
  $("labResState").textContent=`הציונים שנשמרו בזמן ההקלטה · ${labRes.samples.length} הקלטות עם קובץ`;
5155
- $("labRerunAll").disabled=!labRes.samples.length;
 
 
 
 
5156
  drawLabBench(labRes,null,"");
5157
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5158
  $("labRerunAll").onclick=async()=>{
5159
  if(!labRes) return;
5160
  const btn=$("labRerunAll"), ids=labRes.samples, results={}; let failed=0;
@@ -5171,21 +5313,181 @@ $("labRerunAll").onclick=async()=>{
5171
  }catch(e){ labNow=null; $("labResState").textContent=`החישוב נכשל: ${e.message}`; btn.disabled=false; return; }
5172
  labNow.at=new Date().toISOString(); labNow.speaker=$("labWho").value;
5173
  const base=store.get(labBaseKey(),null);
5174
- if(base) drawLabBench(labNow.now,base.bench,`לעומת נקודת ההשוואה: גרסה ${base.version}, ${new Date(base.at).toLocaleString("he-IL")}`);
5175
  else drawLabBench(labNow.now,labNow.stored,"לעומת הציונים שנשמרו בזמן ההקלטה");
5176
- $("labResState").textContent=`נותחו מחדש ${Object.keys(results).length} הקלטות בגרסה ${labNow.version}`+(failed?` · ${failed} נכשלו`:"");
5177
  btn.disabled=false; $("labSaveBase").hidden=false;
5178
  };
5179
  $("labSaveBase").onclick=()=>{
5180
  if(!labNow) return;
5181
- store.set(labBaseKey(),{bench:labNow.now,version:labNow.version,model:labNow.model,at:labNow.at});
5182
  $("labSaveBase").hidden=true; drawLabBase();
5183
- $("labResState").textContent=`נשמרה נקודת השוואה: גרסה ${labNow.version}. שנו את המימוש, ואז הריצו מחדש כדי להשוות.`;
5184
  };
5185
  $("labDropBase").onclick=()=>{ try{ localStorage.removeItem(labBaseKey()); }catch{} drawLabBase(); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5186
  function drawLabBase(){
5187
  const base=store.get(labBaseKey(),null);
5188
- $("labBase").textContent=base?`נקודת השוואה: גרסה ${base.version}, ${new Date(base.at).toLocaleString("he-IL")}`:"אין נקודת השוואה שמורה";
5189
  $("labDropBase").hidden=!base;
5190
  }
5191
  // `cmp`: the benchmark to compare with (null: none). A false rejection rate is better lower; detection and the share
 
456
  #denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
457
  /* The teacher's panel (its parts are styled further down: the class tabs, the figures, the lessons, the students). */
458
  body:not(.manage) #manage,body.lab #manage,body:not(.lab) #lab,body.manage #wrap>header,body.manage #path,body.manage #today,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
459
+ body.lab #wrap{max-width:none}
460
  .mtitle{font:400 24px/1 var(--f-display);margin:0}
461
  #mState.setup{padding:10px 12px;border:1px solid var(--sumac);border-radius:10px;color:var(--ink)}
462
  #mState.setup code{display:block;margin-top:6px;font-size:12px;overflow-wrap:anywhere;user-select:all}
 
470
  .build-info code{direction:ltr;unicode-bidi:isolate;user-select:all}
471
  #manage:not(.flags) #mBuild{display:none}
472
  /* The lab (מעבדה): the diagnostic list by group, each item a button with its count of recordings, and the item on show. */
473
+ #lab{display:flex;flex-direction:column;gap:12px;width:100%;max-width:none;min-width:0;box-sizing:border-box}
474
  #lab>*,.lab-item>*{min-width:0}
475
  #labResult .stage-table-wrap{overflow-x:auto}
476
  .lab-who{flex-direction:row;flex-wrap:wrap;align-items:center;gap:10px 18px}
 
502
  .lab-table th,.lab-table td{padding:5px 8px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}
503
  .lab-table th{color:var(--muted);font-weight:600;font-size:12px}
504
  .lab-table .ipa{direction:ltr;unicode-bidi:isolate;font-family:ui-monospace,monospace}
505
+ #labTraceRec{max-width:100%;min-width:0;flex:1 1 220px}
506
+ .pc-wrap{position:relative;min-width:680px}
507
+ .pc-wrap svg{display:block;width:100%;height:auto;direction:ltr;font:12px var(--f-body);overflow:visible}
508
+ .pc-wrap .pc-axis{stroke:var(--muted);stroke-width:1}
509
+ .pc-wrap .pc-name{fill:var(--ink);font-weight:600;text-anchor:middle}
510
+ .pc-wrap .pc-hint,.pc-wrap .pc-tick{fill:var(--muted)}
511
+ .pc-wrap .pc-plate{fill:var(--surface);pointer-events:none}
512
+ .pc-wrap .pc-label{paint-order:stroke;stroke:var(--surface);stroke-width:4px;stroke-linejoin:round;pointer-events:none}
513
+ .pc-wrap .pc-line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round;transition:opacity .12s}
514
+ .pc-wrap .pc-hit{fill:none;stroke:transparent;stroke-width:12;cursor:pointer}
515
+ .pc-wrap.hl .pc-line{opacity:.12}.pc-wrap.hl .pc-line.on{opacity:1;stroke-width:3.5}
516
+ .pc-tip{position:absolute;pointer-events:none;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:8px;
517
+ padding:6px 9px;font-size:13px;line-height:1.45;box-shadow:0 2px 8px rgba(0,0,0,.15);white-space:nowrap;z-index:2}
518
+ .pc-tip b{font-weight:700}
519
+ .pc-legend{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;font-size:13px;color:var(--muted);margin:6px 0 10px}
520
+ .pc-legend .ramp{display:inline-flex;gap:2px;vertical-align:middle}.pc-legend .ramp i{width:18px;height:10px;border-radius:2px;display:inline-block}
521
+ .pc-legend .dash{display:inline-block;width:26px;border-top:2px dashed var(--muted);vertical-align:middle}
522
+ #labSweepTable tr.on td{background:var(--olive-soft)}
523
+ #labSweepTable tr{cursor:default}
524
  .lab-table tr.focus td:first-child{font-weight:700}
525
  .lab-table td.ok{color:var(--ok)} .lab-table td.mid{color:var(--amber)} .lab-table td.bad{color:var(--sumac);font-weight:700}
526
  .lab-table td.hit{background:var(--ok-bg)} .lab-table td.miss{background:var(--sumac-bg)}
527
+ #minervaTable{table-layout:fixed}
528
+ #minervaTable th:nth-child(1){width:120px}
529
+ #minervaTable th:nth-child(2){width:18%}
530
+ #minervaTable th:nth-child(3){width:auto}
531
+ #minervaTable th:nth-child(4){width:90px}
532
+ #minervaTable th:nth-child(5){width:18%}
533
+ #minervaTable th:nth-child(6){width:112px}
534
+ #minervaTable td{white-space:normal;vertical-align:top;overflow-wrap:anywhere}
535
+ #minervaTable .minerva-phones{direction:ltr;unicode-bidi:isolate;font:13px/1.55 var(--f-ipa)}
536
+ #minervaTable .minerva-phones div+div{margin-top:5px;padding-top:5px;border-top:1px dashed var(--line)}
537
+ #minervaTable .minerva-phones small{display:inline-block;min-width:48px;color:var(--muted);font:11px/1.4 var(--f-body);direction:rtl}
538
  .lab-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
539
+ #labModes{grid-template-columns:repeat(5,1fr)}
540
+ #lab:not(.test) #labTest,#lab:not(.record) #labRecord,#lab:not(.flags) #labFlags,#lab:not(.minerva) #labMinerva,#lab:not(.results) #labResults{display:none}
541
+ #labTest,#labRecord,#labFlags,#labMinerva,#labResults{display:flex;flex-direction:column;gap:12px;min-width:0}
542
  #labList,#labFlagList,#wzList{display:flex;flex-direction:column;gap:10px}
543
  #wzSpeaker{min-width:0;width:12em;padding:6px 10px;border:1px solid var(--line);border-radius:10px;font:inherit;background:var(--surface);color:inherit}
544
  .wz-bar{flex-basis:100%;height:6px;border-radius:3px;background:var(--line);overflow:hidden}
 
1254
  <div class="roundbar"><h1 class="mtitle" id="labTitle">מעבדה</h1><span class="sep"></span><button class="btn" id="labManage">ניהול</button><button class="btn primary" id="labPractice">לתרגול</button></div>
1255
  <p class="sub">רשימה קבועה של הברות ומילים קצרות, שכל אחת בודקת הגה אחד. כל דובר מקריא אותה פעם אחת. אומרים נכון, או בוחרים מראש את ההגה שבו תיעשה טעות מכוונת. כל הקלטה נשמרת עם התווית שלה, ומהן נמדד זיהוי הפונמות: כמה פעמים הגה שנאמר נכון סומן כטעות, וכמה טעויות מכוונות לא נתפסו.</p>
1256
  <div class="sub" id="labState" aria-live="polite"></div>
1257
+ <div class="modes" id="labModes" role="tablist" aria-label="מעבדה"><button role="tab" data-mode="test">מבחן<small>הקלטת כל הרשימה</small></button><button role="tab" data-mode="record">הקלטה<small>פריט בודד, עם טעויות</small></button><button role="tab" data-mode="flags">דיווחים<small>הרצה מחדש של דיווחי ציון</small></button><button role="tab" data-mode="minerva">מינרווה<small>סרטונים ודיבור ילידי</small></button><button role="tab" data-mode="results">תוצאות<small>זיהוי לפי הגה</small></button></div>
1258
  <div id="labTest">
1259
  <div class="card wz-head">
1260
  <p class="sub">כל שורה היא פריט לומר. השורה המסומנת היא הבאה בתור: לוחצים 🎤, אומרים את הפריט נכון ככל האפשר, וההקלטה מושמעת לבד. אחרי ההשמעה: ✓ שולח אותה ועובר לשורה הבאה, ✗ מוחק אותה ומקליטים שוב. מה שנשלח נשמר כ"נאמר נכון" של הדובר, והוא הבסיס לבדיקת זיהוי הפונמות.</p>
 
1294
  </div>
1295
  <div id="labFlagList"></div>
1296
  </div>
1297
+ <div id="labMinerva">
1298
+ <div class="card">
1299
+ <p class="sub">בחרו סרטון כדי לנתח מחדש את כל קטעי הדיבור שלו. הטבלה משווה בין התוצאה שנשמרה בייבוא לבין ה־pipeline והגדרת ה־GOP הנוכחיים.</p>
1300
+ <div class="lab-actions"><label>סרטון <select id="minervaVideo"></select></label><label>הגדרת GOP <select id="minervaSetting"></select></label><button class="btn primary" id="minervaRun">הרצת הסרטון</button><span class="sub" id="minervaState" aria-live="polite"></span></div>
1301
+ <div class="lab-tiles" id="minervaTiles"></div>
1302
+ </div>
1303
+ <div class="card"><div class="stage-table-wrap"><table class="lab-table" id="minervaTable"></table></div></div>
1304
+ </div>
1305
  <div id="labResults">
1306
  <div class="card">
1307
+ <div class="lab-actions"><label>דובר <select id="labWho"><option value="">כולם</option></select></label><button class="btn primary" id="labRerunAll">הרצה מחדש עם הגרסה הנוכחית</button><button class="btn" id="labSweep" disabled title="כל הקלטה מנותחת בכל 24 השילובים של GOP_RATIO_POOL ו-GOP_VOWEL_WINDOW, בלי לשנות את השרת">השוואת 24 הגדרות</button><button class="btn" id="labSaveBase" hidden>שמירה כנקודת השוואה</button><button class="btn danger" id="labDelete" disabled title="מוחק את ההקלטות ואת קובצי השמע שלהן, כדי להקליט את הרשימה מחדש">מחיקת ההקלטות</button></div>
1308
  <div class="lab-actions"><span class="sub" id="labResState" aria-live="polite"></span></div>
1309
+ <div class="lab-actions" id="labTraceRow" hidden><label>לוח אבחון להקלטה <select id="labTraceShow"><option value="">כל ההקלטות</option><option value="flagged">עם הגה שסומן</option><option value="unrelated">"משפט אחר"</option><option value="mistake">טעות מכוונת</option></select></label><select id="labTraceRec" aria-label="הקלטה"></select><label>בהגדרה <select id="labTraceSet"></select></label><button class="btn" id="labTraceOpen" title="ההקלטה מנותחת מחדש בגרסה הנוכחית, והשלבים נפתחים בלשונית חדשה">לוח אבחון</button></div>
1310
  <div class="lab-actions"><span class="sub" id="labBase"></span><button class="btn" id="labDropBase" hidden>מחיקה</button></div>
1311
  <p class="sub" id="labCmp"></p>
1312
  <div class="lab-tiles" id="labTiles"></div>
1313
  <p class="sub">איך בודקים שינוי במימוש: הריצו מחדש ושמרו כנקודת השוואה, שנו את המימוש, והריצו מחדש. ירוק: השתפר, אדום: הורע. שיפור אמיתי מוריד את "סומנו בטעות" בלי להוריד את "זיהוי". "סומן בטעות": הגה שנאמר נכון וסומן כטעות. "זיהוי": טעות מכוונת שסומנה. "דיווחים": ההגה סומן בטעות בדיווח מאושר מתרגול אמיתי. אותה בדיקה משורת הפקודה: <code>python scripts/bench.py</code></p>
1314
  </div>
1315
  <div class="card"><div class="stage-table-wrap"><table class="lab-table" id="labBench"></table></div></div>
1316
+ <div class="card" id="labSweepBox" hidden>
1317
+ <h3>השוואת הגדרות GOP</h3>
1318
+ <p class="sub" id="labSweepSum"></p>
1319
+ <p class="sub">המדד: <b>איכות</b> = ממוצע של (100% פחות "סומנו בטעות") ושל "זיהוי" (דיוק מאוזן). כל חצי הוא שיעור, ולכן ההגאים הרבים שנאמרו נכון לא מבטלים את הטעויות המכוונות המעטות, והגדרה לא יכולה לנצח בכך שאינה מסמנת דבר או מסמנת הכל. בכל ציר מדד, למעלה = טוב יותר.</p>
1320
+ <p class="sub">תנועות: המערכת לא מסמנת תנועה כטעות (מלבד u אחרי עיצור, כלל 3.11), ולכן הגדרות האיגום והחלון, שמשנות רק תנועות, כמעט לא מזיזות את "סומנו בטעות" ואת "זיהוי". שלושת צירי התנועות מודדים את הציון עצמו: <b>תנועות חלשות</b> = תנועות שנאמרו נכון וקיבלו פחות מ-75 (נצבעות לתלמיד כחלשות), <b>ציון תנועות</b> = הממוצע שלהן, <b>הפרדת תנועות</b> = הסיכוי שתנועה נכונה תקבל ציון גבוה יותר מתנועה שנאמרה בטעות מכוונת (1 = הפרדה מלאה, 0.5 = ניחוש). כשהאיכות שווה, הדירוג נקבע לפי הצירים האלה. <b>ציונים ששונו</b>: כמה ציוני הגאים ההגדרה נתנה אחרת מ-max · חלון רחב (החישוב שלפני השינויים), בדיקה ישירה שההגדרה אכן הופעלה.</p>
1321
+ <p class="sub setup" id="labSweepNote" hidden>אין עדיין טעויות מכוונות בהקלטות האלה, ולכן האיכות כאן היא רק 100% פחות "סומנו בטעות": היא לא יודעת אם הגדרה מפספסת טעויות אמיתיות. הקליטו טעויות מכוונות בטאב הקלטה.</p>
1322
+ <div class="stage-table-wrap"><div class="pc-wrap" id="labSweepPlot"></div></div>
1323
+ <div class="pc-legend" id="labSweepLegend"></div>
1324
+ <div class="stage-table-wrap"><table class="lab-table" id="labSweepTable"></table></div>
1325
+ </div>
1326
  </div>
1327
  </section>
1328
  </div>
 
4854
  labMode(store.get("ihki.labMode","test"));
4855
  }
4856
  function labMode(mode){
4857
+ if(!["test","record","flags","minerva","results"].includes(mode)) mode="test";
4858
  store.set("ihki.labMode",mode);
4859
  for(const b of $("labModes").children) b.setAttribute("aria-selected",b.dataset.mode===mode?"true":"false");
4860
+ for(const m of ["test","record","flags","minerva","results"]) $("lab").classList.toggle(m,m===mode);
4861
  if(mode==="test"){ drawWizard(); loadTtsVoices(); }
4862
  if(mode==="flags"&&!labFlags) loadLabFlags();
4863
+ if(mode==="minerva") loadMinerva();
4864
  if(mode==="results") loadLabResults();
4865
  }
4866
  for(const b of $("labModes").children) b.onclick=()=>labMode(b.dataset.mode);
 
5189
  btn.disabled=false; drawLabFlags();
5190
  };
5191
 
5192
+ /* Native Minerva pilot: its imported rows already contain the cut timestamps, expected text and stored result. */
5193
+ let minervaData=null, minervaNow={};
5194
+ const minervaTime=n=>n==null?"—":`${Math.floor(n/60)}:${String((n%60).toFixed(1)).padStart(4,"0")}`;
5195
+ const minervaFlagged=r=>(r.words||[]).flatMap(w=>(w.phonemes||[]).filter(p=>p.flagged).map(p=>`${p.ipa}>${p.heard||"_"}`));
5196
+ function fillGopSettings(sel){
5197
+ if(sel.options.length) return;
5198
+ sel.append(new Option("של השרת",""));
5199
+ for(const k of LAB_SETTINGS){ const [p,w]=k.split("|"); sel.append(new Option(`${p} · ${sweepWin(w)}`,k)); }
5200
+ }
5201
+ async function loadMinerva(){
5202
+ const state=$("minervaState");
5203
+ if(!minervaData){
5204
+ state.textContent="טוען סרטונים…";
5205
+ try{ const res=await api("/api/lab/minerva"), d=await res.json().catch(()=>({})); if(!res.ok) throw new Error(d.detail||res.status); minervaData=d; }
5206
+ catch(e){ state.textContent=`הסרטונים לא זמינים: ${e.message}`; return; }
5207
+ const sel=$("minervaVideo"), was=sel.value; sel.replaceChildren();
5208
+ for(const v of minervaData.videos) sel.append(new Option(`${v.title} · ${v.samples.length} קטעים`,v.id));
5209
+ if([...sel.options].some(o=>o.value===was)) sel.value=was;
5210
+ fillGopSettings($("minervaSetting"));
5211
+ }
5212
+ state.textContent=minervaData.videos.length?`גרסה ${minervaData.version}`:"לא נמצאו הקלטות מיובאות של מינרווה";
5213
+ drawMinerva();
5214
+ }
5215
+ function drawMinerva(){
5216
+ const video=minervaData&&minervaData.videos.find(v=>v.id===$("minervaVideo").value), table=$("minervaTable"), tiles=$("minervaTiles");
5217
+ table.replaceChildren(); tiles.replaceChildren(); $("minervaRun").disabled=!video;
5218
+ if(!video) return;
5219
+ const results=minervaNow[video.id]||{}, done=Object.keys(results).length;
5220
+ const current=Object.values(results), bad=current.filter(r=>r.unrelated).length;
5221
+ const flags=current.reduce((n,r)=>n+minervaFlagged(r).length,0);
5222
+ for(const [value,label] of [[video.samples.length,"קטעי דיבור"],[done,"נותחו עכשיו"],[flags,"סימוני הגאים"],[bad,'“משפט אחר”']]){
5223
+ const box=mark("lab-tile","","div"); box.append(mark("",String(value),"b"),mark("",label,"small")); tiles.append(box);
5224
+ }
5225
+ const head=document.createElement("thead"); head.innerHTML="<tr><th>קטע וזמן</th><th>ערבית</th><th>פונמות</th><th>ציון</th><th>סימונים</th><th></th></tr>"; table.append(head);
5226
+ const body=document.createElement("tbody");
5227
+ for(const s of video.samples){
5228
+ const now=results[s.id], tr=document.createElement("tr"), flagsNow=now?minervaFlagged(now):null;
5229
+ const storedFlags=s.flagged.join(", ")||"—";
5230
+ const values=[`${s.line} · ${minervaTime(s.start)}–${minervaTime(s.end)}`,s.ar,
5231
+ now?`${s.score??"—"} → ${now.score??"—"}`:s.score??"—",
5232
+ now?`${storedFlags} → ${flagsNow.join(", ")||"—"}`:storedFlags];
5233
+ for(const value of values.slice(0,2)){ const td=document.createElement("td"); td.textContent=value; tr.append(td); }
5234
+ const phones=mark("minerva-phones","","td"), expected=document.createElement("div"), heard=document.createElement("div");
5235
+ expected.append(mark("","צפוי","small"),document.createTextNode(s.ipa));
5236
+ heard.append(mark("","נשמע","small"),document.createTextNode(now?`${s.heard_ipa||"—"} → ${now.heard_ipa||"—"}`:s.heard_ipa||"—"));
5237
+ phones.append(expected,heard); tr.append(phones);
5238
+ for(const value of values.slice(2)){ const td=document.createElement("td"); td.textContent=value; tr.append(td); }
5239
+ if(now){ tr.children[3].className=(now.score??0)>=(s.score??0)?"ok":"bad"; tr.children[4].className=flagsNow.length?"bad":"ok"; }
5240
+ const td=document.createElement("td"), button=mark("btn","לוח אבחון","button"); button.disabled=!s.audio;
5241
+ button.onclick=()=>openLabTrace(s.id,$("minervaSetting").value,$("minervaState"),button); td.append(button); tr.append(td); body.append(tr);
5242
+ }
5243
+ table.append(body);
5244
+ }
5245
+ $("minervaVideo").onchange=drawMinerva;
5246
+ $("minervaRun").onclick=async()=>{
5247
+ const video=minervaData&&minervaData.videos.find(v=>v.id===$("minervaVideo").value); if(!video) return;
5248
+ const setting=$("minervaSetting").value, results={}, btn=$("minervaRun"); let failed=0; btn.disabled=true;
5249
+ for(const [i,s] of video.samples.entries()){
5250
+ $("minervaState").textContent=`מנתח קטע ${i+1} מתוך ${video.samples.length}…`;
5251
+ try{ const res=await api(`/api/lab/samples/${s.id}/rescore`+(setting?`?setting=${encodeURIComponent(setting)}`:""),{method:"POST"}), d=await res.json().catch(()=>({})); if(!res.ok) throw new Error(d.detail||res.status); results[s.id]=d.result; }
5252
+ catch{ failed++; }
5253
+ minervaNow[video.id]=results; drawMinerva();
5254
+ }
5255
+ $("minervaState").textContent=`נותחו ${Object.keys(results).length} מתוך ${video.samples.length} קטעים`+(failed?` · ${failed} נכשלו`:""); btn.disabled=false;
5256
+ };
5257
+
5258
  /* The lab's results: per sound, of the recordings as they were scored (see lab.benchmark in app/lab.py). */
5259
  /* The lab's results: per sound (see lab.benchmark in app/lab.py). As loaded, the recordings as they were scored at
5260
  the time; "הרצה מחדש" rescores them all with the version the server runs now, and compares that with a saved run
5261
  (the comparison point, kept on this device) or, without one, with the stored scores of the same recordings. */
5262
  let labRes=null, labNow=null; // the stored benchmark (with the recordings' ids), and the latest rescore's
5263
  const labBaseKey=()=>"ihki.labBase:"+$("labWho").value; // a comparison point for each speaker filter
5264
+ // a run's version with its GOP pool (GOP_RATIO_POOL): the env picks the pool, so the code's version cannot tell them apart
5265
+ const labTag=d=>d.version+(d.pool?` · ${d.pool}`:"")+(d.vowel_window&&d.vowel_window!=="wide"?` · חלון תנועה ${d.vowel_window}`:"");
5266
  async function loadLabResults(){
5267
  const who=$("labWho").value; $("labResState").textContent="טוען…"; labNow=null;
5268
  try{ const res=await api("/api/lab/results"+(who?`?speaker=${encodeURIComponent(who)}`:"")); labRes=await res.json().catch(()=>({}));
 
5271
  const sel=$("labWho"); sel.replaceChildren(new Option("כולם",""));
5272
  for(const s of labRes.speakers) sel.append(new Option(s||"(בלי שם)",s)); sel.value=who;
5273
  $("labResState").textContent=`הציונים שנשמרו בזמן ההקלטה · ${labRes.samples.length} הקלטות עם קובץ`;
5274
+ $("labRerunAll").disabled=$("labSweep").disabled=!labRes.samples.length;
5275
+ $("labSweepBox").hidden=true; // a sweep belongs to the filter it ran on
5276
+ drawLabTracePicker();
5277
+ const del=$("labDelete"); del.disabled=!labRes.totals.recordings;
5278
+ del.textContent=who?`מחיקת ההקלטות של ${who}`:"מחיקת כל ההקלטות";
5279
  drawLabBench(labRes,null,"");
5280
  }
5281
+ // Deletes the recordings of the speaker the filter shows, or of every speaker under "כולם", to record afresh. The
5282
+ // comparison points saved of them go too: they measured recordings that are gone.
5283
+ $("labDelete").onclick=async()=>{
5284
+ if(!labRes) return;
5285
+ const who=$("labWho").value, n=labRes.totals.recordings, word="מחיקה";
5286
+ if(who){ if(!confirm(`למחוק את ${n} ההקלטות של ${who}, עם קובצי השמע שלהן? לא ניתן לבטל את המחיקה.`)) return; }
5287
+ else if((prompt(`פעולה זו מוחקת את כל ${n} ההקלטות של כל הדוברים, עם קובצי השמע שלהן, ולא ניתן לבטל אותה. כדי לאשר, הקלידו: ${word}`)||"").trim()!==word) return;
5288
+ const btn=$("labDelete"); btn.disabled=true; $("labResState").textContent="מוחק…";
5289
+ try{
5290
+ const res=await api("/api/lab/samples?"+(who?`speaker=${encodeURIComponent(who)}`:"everyone=true"),{method:"DELETE"}), d=await res.json().catch(()=>({}));
5291
+ if(!res.ok) throw new Error(d.detail||res.status);
5292
+ try{ localStorage.removeItem(labBaseKey()); localStorage.removeItem("ihki.labBase:"); }catch{}
5293
+ $("labWho").value=""; labNow=null; $("labSaveBase").hidden=true; drawLabBase();
5294
+ await loadLabResults();
5295
+ $("labResState").textContent=`נמחקו ${d.deleted} הקלטות.`;
5296
+ const me=store.get("ihki.labSpeaker",""), items=await api("/api/lab"+(me?`?speaker=${encodeURIComponent(me)}`:""));
5297
+ if(items.ok&&LAB){ const l=await items.json(); LAB.counts=l.counts; LAB.mine=l.mine; drawLabList(); }
5298
+ }catch(e){ $("labResState").textContent=`המחיקה נכשלה: ${e.message}`; btn.disabled=false; }
5299
+ };
5300
  $("labRerunAll").onclick=async()=>{
5301
  if(!labRes) return;
5302
  const btn=$("labRerunAll"), ids=labRes.samples, results={}; let failed=0;
 
5313
  }catch(e){ labNow=null; $("labResState").textContent=`החישוב נכשל: ${e.message}`; btn.disabled=false; return; }
5314
  labNow.at=new Date().toISOString(); labNow.speaker=$("labWho").value;
5315
  const base=store.get(labBaseKey(),null);
5316
+ if(base) drawLabBench(labNow.now,base.bench,`לעומת נקודת ההשוואה: גרסה ${labTag(base)}, ${new Date(base.at).toLocaleString("he-IL")}`);
5317
  else drawLabBench(labNow.now,labNow.stored,"לעומת הציונים שנשמרו בזמן ההקלטה");
5318
+ $("labResState").textContent=`נותחו מחדש ${Object.keys(results).length} הקלטות בגרסה ${labTag(labNow)}`+(failed?` · ${failed} נכשלו`:"");
5319
  btn.disabled=false; $("labSaveBase").hidden=false;
5320
  };
5321
  $("labSaveBase").onclick=()=>{
5322
  if(!labNow) return;
5323
+ store.set(labBaseKey(),{bench:labNow.now,version:labNow.version,pool:labNow.pool,vowel_window:labNow.vowel_window,model:labNow.model,at:labNow.at});
5324
  $("labSaveBase").hidden=true; drawLabBase();
5325
+ $("labResState").textContent=`נשמרה נקודת השוואה: גרסה ${labTag(labNow)}. שנו את המימוש, ואז הריצו מחדש כדי להשוות.`;
5326
  };
5327
  $("labDropBase").onclick=()=>{ try{ localStorage.removeItem(labBaseKey()); }catch{} drawLabBase(); };
5328
+ /* The trace dashboard for any saved recording: rescored now (the current version, and the server's GOP setting or one
5329
+ of the sweep's), with the teacher's trace, then opened as the phoneme dialog opens it. */
5330
+ const LAB_SETTINGS=["max","mean","topk","lse","energy","gated"].flatMap(p=>["wide","0","1","2"].map(w=>`${p}|${w}`));
5331
+ function drawLabTracePicker(){
5332
+ const recs=(labRes&&labRes.recordings)||[], show=$("labTraceShow").value, sel=$("labTraceRec"), was=sel.value;
5333
+ $("labTraceRow").hidden=!recs.length;
5334
+ const item=id=>(LAB&&LAB.items.find(x=>x.id===id))||{};
5335
+ const keep=r=>!show||(show==="flagged"?r.flagged.length&&!r.unrelated:show==="unrelated"?r.unrelated:r.label==="mistake");
5336
+ sel.replaceChildren();
5337
+ for(const r of recs.filter(keep)){
5338
+ const it=item(r.item), when=r.created_at?new Date(r.created_at).toLocaleDateString("he-IL"):"";
5339
+ const what=r.unrelated?"משפט אחר":r.flagged.length?`סומן: ${r.flagged.join(", ")}`:"בלי סימון";
5340
+ sel.append(new Option(`${it.ar||r.item} (${it.focus||"?"}) · ${r.speaker||"בלי שם"} · ${r.score??"—"} · ${what}`+
5341
+ (r.label==="mistake"?" · טעות מכוונת":"")+(when?` · ${when}`:""),String(r.id)));
5342
+ }
5343
+ if([...sel.options].some(o=>o.value===was)) sel.value=was;
5344
+ $("labTraceOpen").disabled=!sel.options.length;
5345
+ const set=$("labTraceSet");
5346
+ if(!set.options.length){ set.append(new Option("של השרת","")); for(const k of LAB_SETTINGS){ const [p,w]=k.split("|"); set.append(new Option(`${p} · ${sweepWin(w)}`,k)); } }
5347
+ }
5348
+ $("labTraceShow").onchange=drawLabTracePicker;
5349
+ async function openLabTrace(id,setting,state,btn){
5350
+ if(!id) return;
5351
+ const tab=window.open("about:blank","_blank"); // opened on the click, so no popup blocker stops it after the wait
5352
+ btn.disabled=true; state.textContent="מנתח את ההקלטה מחדש…";
5353
+ try{
5354
+ const res=await api(`/api/lab/samples/${id}/rescore?trace=true`+(setting?`&setting=${encodeURIComponent(setting)}`:""),{method:"POST"});
5355
+ const d=await res.json().catch(()=>({})); if(!res.ok) throw new Error(d.detail||res.status);
5356
+ localStorage.setItem("ihki.trace",JSON.stringify(d.result));
5357
+ if(tab){ tab.opener=null; tab.location.href="/static/trace.html?src=app"; } else window.open("/static/trace.html?src=app","_blank","noopener");
5358
+ state.textContent=`נפתח לוח האבחון של ההקלטה, בהגדרה ${d.setting}`;
5359
+ }catch(e){ if(tab) tab.close(); state.textContent=`לוח האבחון לא נפתח: ${e.message}`; }
5360
+ btn.disabled=false;
5361
+ }
5362
+ $("labTraceOpen").onclick=()=>{
5363
+ openLabTrace($("labTraceRec").value,$("labTraceSet").value,$("labResState"),$("labTraceOpen"));
5364
+ };
5365
+ /* The sweep: every recording the filter shows, scored under each GOP setting (lab.SWEEP: GOP_RATIO_POOL x
5366
+ GOP_VOWEL_WINDOW) without changing the server; then the benchmark of each setting, ranked by its quality
5367
+ (lab.quality, balanced accuracy), and drawn as parallel coordinates: one line per setting through its two settings
5368
+ and its metrics, coloured by the quality on a one-hue ramp (validated: dataviz ordinal check, light and dark). */
5369
+ let labSweepRes=null;
5370
+ const SWEEP_RAMP={light:["#86b6ef","#5598e7","#2a78d6","#1c5cab","#104281"],dark:["#1c5cab","#2a78d6","#5598e7","#86b6ef","#b7d3f6"]};
5371
+ const sweepWin=w=>w==="wide"?"רחב":`±${w}`, sweepPct=x=>x==null?"—":`${(100*x).toFixed(1)}%`, sweepQ=x=>x==null?"—":x.toFixed(1);
5372
+ $("labSweep").onclick=async()=>{
5373
+ if(!labRes) return;
5374
+ const btn=$("labSweep"), ids=labRes.samples, results={}; let failed=0;
5375
+ btn.disabled=$("labRerunAll").disabled=true;
5376
+ for(const [k,id] of ids.entries()){
5377
+ $("labResState").textContent=`משווה 24 הגדרות: הקלטה ${k+1} מתוך ${ids.length}…`;
5378
+ try{ const res=await api(`/api/lab/samples/${id}/sweep`,{method:"POST"}), d=await res.json().catch(()=>({}));
5379
+ if(!res.ok) throw new Error(d.detail||res.status); results[id]=d.results; }
5380
+ catch{ failed++; }
5381
+ }
5382
+ try{
5383
+ const res=await api("/api/lab/sweep",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({results})});
5384
+ labSweepRes=await res.json().catch(()=>({})); if(!res.ok) throw new Error(labSweepRes.detail||res.status);
5385
+ }catch(e){ labSweepRes=null; $("labResState").textContent=`ההשוואה נכשלה: ${e.message}`; btn.disabled=$("labRerunAll").disabled=false; return; }
5386
+ $("labResState").textContent=`הושוו ${labSweepRes.settings.length} הגדרות על ${labSweepRes.recordings} הקלטות בגרסה ${labSweepRes.version}`+(failed?` · ${failed} נכשלו`:"");
5387
+ btn.disabled=$("labRerunAll").disabled=false; drawSweep(labSweepRes);
5388
+ };
5389
+ try{ matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{ if(labSweepRes&&!$("labSweepBox").hidden) drawSweep(labSweepRes); }); }catch{}
5390
+ function drawSweep(d){
5391
+ $("labSweepBox").hidden=false;
5392
+ const ramp=SWEEP_RAMP[matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"];
5393
+ const all=d.settings, rows=all.filter(r=>r.quality!=null), best=rows[0], cur=all.find(r=>r.key===d.current);
5394
+ const name=r=>`${r.pool} · חלון ${sweepWin(r.window)}`;
5395
+ const qs=rows.map(r=>r.quality), qlo=Math.min(...qs), qhi=Math.max(...qs);
5396
+ const tie=rows.length>1&&rows.every(r=>r.quality===best.quality&&JSON.stringify([r.totals,r.vowels])===JSON.stringify([best.totals,best.vowels]));
5397
+ // near-tie: the flag metrics and the weak-vowel share equal, the vowel average within half a point
5398
+ const vm=rows.map(r=>r.vowels&&r.vowels.mean).filter(x=>x!=null), vspread=vm.length?Math.max(...vm)-Math.min(...vm):0;
5399
+ const near=!tie&&rows.length>1&&qhi-qlo<1e-9&&rows.every(r=>JSON.stringify(r.totals)===JSON.stringify(best.totals)
5400
+ &&r.vowels&&best.vowels&&r.vowels.weak===best.vowels.weak&&r.vowels.auc===best.vowels.auc)&&vspread<.5;
5401
+ $("labSweepSum").textContent=!best?"אין הקלטות שנותחו בהשוואה.":near?
5402
+ `כל ${rows.length} ההגדרות כמעט זהות: כל המדדים שווים, וההבדל היחיד הוא ${vspread.toFixed(1)} נקודות בממוצע ציון התנועות. על ההקלטות האלה הבחירה לא משנה.`:tie?
5403
+ `כל ${rows.length} ההגדרות יצאו זהות (איכות ${sweepQ(best.quality)}): על ההקלטות האלה הבחירה לא משנה אף מדד.`:
5404
+ `הטובה ביותר: ${name(best)}, איכות ${sweepQ(best.quality)}`+(qhi-qlo<1e-9?" (האיכות שווה בכל ההגדרות, והדירוג נקבע לפי צירי התנועות)":"")+(cur===best?" (זו ההגדרה הנוכחית).":cur?` · ההגדרה הנוכחית (${name(cur)}): ${sweepQ(cur.quality)}.`:".");
5405
+ $("labSweepNote").hidden=!rows.length||!rows.every(r=>r.partial);
5406
+ // the axes: the two settings (categories), then the metrics, each with better up
5407
+ const order=(fixed,f)=>{ const seen=new Set(all.map(f)); return fixed.filter(x=>seen.has(x)).concat([...seen].filter(x=>!fixed.includes(x))); };
5408
+ const axes=[
5409
+ {name:"איגום",hint:"GOP_RATIO_POOL",cat:order(["max","mean","topk","lse","energy","gated"],r=>r.pool),get:r=>r.pool,fmt:x=>x},
5410
+ {name:"חלון תנועה",hint:"GOP_VOWEL_WINDOW",cat:order(["wide","0","1","2"],r=>String(r.window)),get:r=>String(r.window),fmt:sweepWin},
5411
+ {name:"סומנו בטעות",hint:"↑ פחות",get:r=>r.totals.frr,fmt:sweepPct,lower:true},
5412
+ {name:"זיהוי",hint:"↑ יותר",get:r=>r.totals.detection,fmt:sweepPct},
5413
+ {name:"ציון 85+",hint:"↑ יותר",get:r=>r.totals.good_share,fmt:sweepPct},
5414
+ {name:"תנועות חלשות",hint:"נכונות מתחת ל-75 ↑ פחות",get:r=>r.vowels&&r.vowels.weak,fmt:sweepPct,lower:true},
5415
+ {name:"ציון תנועות",hint:"נכונות, ממוצע ↑",get:r=>r.vowels&&r.vowels.mean,fmt:sweepQ},
5416
+ {name:"הפרדת תנועות",hint:"AUC ↑ יותר",get:r=>r.vowels&&r.vowels.auc,fmt:x=>x==null?"—":x.toFixed(2)},
5417
+ {name:"איכות",hint:"↑ טוב יותר",get:r=>r.quality,fmt:sweepQ},
5418
+ ].filter(a=>a.cat||rows.some(r=>a.get(r)!=null));
5419
+ const W=880,H=330,M={t:52,b:26,l:70,r:58},top=M.t,bot=H-M.b,step=(W-M.l-M.r)/(axes.length-1);
5420
+ for(const [i,a] of axes.entries()){
5421
+ a.x=M.l+i*step;
5422
+ if(a.cat){ a.y=v=>{ const k=a.cat.indexOf(v); return a.cat.length<2?(top+bot)/2:top+k*(bot-top)/(a.cat.length-1); }; continue; }
5423
+ const vals=rows.map(a.get).filter(v=>v!=null), lo=Math.min(...vals), hi=Math.max(...vals);
5424
+ a.lo=lo; a.hi=hi; a.flat=hi-lo<1e-9; // every setting the same: one value mid-axis, no invented range
5425
+ a.y=v=>v==null?bot:a.flat?(top+bot)/2:(a.lower?top+(v-lo)/(hi-lo)*(bot-top):bot-(v-lo)/(hi-lo)*(bot-top));
5426
+ }
5427
+ // colour by rank (the server's order: quality, then the vowel axes), best darkest on light / lightest on dark
5428
+ const rank=new Map(all.map((r,k)=>[r.key,k]));
5429
+ const colour=r=>ramp[r.quality==null?0:4-Math.min(4,Math.floor(rank.get(r.key)/all.length*5))];
5430
+ const NS="http://www.w3.org/2000/svg", el=(tag,attrs,text)=>{ const e=document.createElementNS(NS,tag); for(const k in attrs) e.setAttribute(k,attrs[k]); if(text!=null) e.textContent=text; return e; };
5431
+ const svg=el("svg",{viewBox:`0 0 ${W} ${H}`,role:"img","aria-label":`עקומות מקבילות של ${all.length} הגדרות; הנתונים בטבלה שמתחת`});
5432
+ const labels=[]; // drawn over the lines, with a halo of the surface
5433
+ for(const a of axes){
5434
+ svg.append(el("line",{class:"pc-axis",x1:a.x,x2:a.x,y1:top,y2:bot}));
5435
+ labels.push(el("text",{class:"pc-name",x:a.x,y:16},a.name), el("text",{class:"pc-hint",x:a.x,y:32,"text-anchor":"middle","font-size":11},a.hint));
5436
+ if(a.cat) for(const c of a.cat) labels.push(el("text",{class:"pc-tick",x:a.x-6,y:a.y(c)+4,"text-anchor":"end"},a.fmt(c)));
5437
+ else if(a.flat){ const last=a===axes[axes.length-1];
5438
+ labels.push(el("text",{class:"pc-tick",x:last?a.x-6:a.x+6,y:(top+bot)/2+4,"text-anchor":last?"end":"start"},`${a.fmt(a.lo)} (כולן)`)); }
5439
+ else{
5440
+ const [up,down]=a.lower?[a.lo,a.hi]:[a.hi,a.lo];
5441
+ labels.push(el("text",{class:"pc-tick",x:a.x+6,y:top+4},a.fmt(up)), el("text",{class:"pc-tick",x:a.x+6,y:bot+4},a.fmt(down)));
5442
+ }
5443
+ }
5444
+ const wrap=$("labSweepPlot"), tip=mark("pc-tip",""); tip.hidden=true;
5445
+ const lines={}, trs={};
5446
+ const hl=key=>{ wrap.classList.toggle("hl",!!key); for(const k in lines) lines[k].classList.toggle("on",k===key);
5447
+ for(const k in trs) trs[k].classList.toggle("on",k===key); };
5448
+ // the worst first, so the best are drawn on top
5449
+ for(const r of [...all].sort((x,y)=>(x.quality??-1)-(y.quality??-1))){
5450
+ const pts=axes.map(a=>`${a.x},${a.y(a.get(r)).toFixed(1)}`).join(" ");
5451
+ const line=el("polyline",{class:"pc-line",points:pts,stroke:colour(r)});
5452
+ if(r.key===d.current) line.setAttribute("stroke-dasharray","7 4");
5453
+ const hit=el("polyline",{class:"pc-hit",points:pts});
5454
+ hit.addEventListener("mouseenter",()=>hl(r.key));
5455
+ hit.addEventListener("mousemove",ev=>{
5456
+ tip.replaceChildren(mark("",name(r)+(r.key===d.current?" (נוכחית)":""),"b"));
5457
+ for(const a of axes.slice(2)) tip.append(document.createElement("br"),`${a.name}: ${a.fmt(a.get(r))}`);
5458
+ tip.hidden=false; const box=wrap.getBoundingClientRect(), x=ev.clientX-box.left, y=ev.clientY-box.top;
5459
+ tip.style.left=`${Math.min(x+14,box.width-tip.offsetWidth-4)}px`; tip.style.top=`${Math.max(0,y-tip.offsetHeight-10)}px`; });
5460
+ hit.addEventListener("mouseleave",()=>{ hl(null); tip.hidden=true; });
5461
+ lines[r.key]=line; svg.append(line,hit);
5462
+ }
5463
+ for(const t of labels) t.classList.add("pc-label");
5464
+ svg.append(...labels);
5465
+ wrap.replaceChildren(svg,tip);
5466
+ for(const t of labels) if(t.classList.contains("pc-tick")){ // a plate under each tick, so a line along it does not cross it
5467
+ const b=t.getBBox(); if(b.width) svg.insertBefore(el("rect",{class:"pc-plate",x:b.x-3,y:b.y-1,width:b.width+6,height:b.height+2,rx:3}),t); }
5468
+ // the legend: the ramp, and the current setting's dash
5469
+ const lg=$("labSweepLegend"), sw=mark("ramp","");
5470
+ for(const c of ramp){ const i=document.createElement("i"); i.style.background=c; sw.append(i); }
5471
+ lg.replaceChildren(mark("",`דירוג ${all.length}`),sw,mark("","1"),mark("dash",""),mark("","ההגדרה הנוכחית"));
5472
+ // the table: the same numbers, best first
5473
+ const tb=$("labSweepTable"); tb.replaceChildren();
5474
+ const head=document.createElement("tr");
5475
+ for(const h of ["#","איגום","חלון תנועה","איכות","סומנו בטעות","זיהוי","ציון 85+","תנועות חלשות","ציון תנועות","הפרדת תנועות","ציונים ששונו","משפט אחר"]) head.append(mark("",h,"th"));
5476
+ tb.append(head);
5477
+ for(const [k,r] of all.entries()){
5478
+ const tr=document.createElement("tr"), t=r.totals, v=r.vowels||{};
5479
+ for(const cell of [String(k+1),r.pool+(r.key===d.current?" (נוכחית)":""),sweepWin(r.window),sweepQ(r.quality)+(r.partial&&r.quality!=null?"*":""),
5480
+ `${sweepPct(t.frr)} (${t.false_flags}/${t.right})`,t.planted?`${sweepPct(t.detection)} (${t.caught}/${t.planted})`:"—",sweepPct(t.good_share),
5481
+ v.right?`${sweepPct(v.weak)} (${v.right})`:"—",sweepQ(v.mean),v.auc==null?"—":`${v.auc.toFixed(2)} (${v.planted})`,
5482
+ r.key==="max|wide"?"בסיס":`${r.changed??"—"} מתוך ${r.sounds??"—"}`,String(t.unrelated)])
5483
+ tr.append(mark("",cell,"td"));
5484
+ tr.onmouseenter=()=>hl(r.key); tr.onmouseleave=()=>hl(null);
5485
+ trs[r.key]=tr; tb.append(tr);
5486
+ }
5487
+ }
5488
  function drawLabBase(){
5489
  const base=store.get(labBaseKey(),null);
5490
+ $("labBase").textContent=base?`נקודת השוואה: גרסה ${labTag(base)}, ${new Date(base.at).toLocaleString("he-IL")}`:"אין נקודת השוואה שמורה";
5491
  $("labDropBase").hidden=!base;
5492
  }
5493
  // `cmp`: the benchmark to compare with (null: none). A false rejection rate is better lower; detection and the share
tests/test_gop_pooling.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GOP frame pooling (align.score_tokens with GopConfig.ratio_pool) on synthetic CTC emissions.
2
+
3
+ The phrase is /k a t/; the vowel /a/ is the token under test. Every frame is a distribution: the named
4
+ sounds get their probability, a 1e-4 floor goes to the rest, blank takes what is left (CTC is peaky).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, replace
9
+
10
+ import numpy as np
11
+ import pytest
12
+
13
+ from app.align import Alignment, GopConfig, POOLS, energy_weights, pool_frames, score_tokens, spread_energy
14
+
15
+ BLANK, K, A, I, T_, SCHWA = 0, 1, 2, 3, 4, 5
16
+ V = 6
17
+ TOKENS = [K, A, T_]
18
+ N_FRAMES = 14
19
+ SPANS = [(1, 2), (5, 7), (12, 13)] # k at 1, a around 5-6, t at 12: the window of /a/ is frames 2..11
20
+ LOUD = np.array([.2, .6, .5, 1, 1, 1, 1, 1, .9, .7, .15, .1, .4, .1]) # vowel nucleus 3..9, quiet release 10..11
21
+
22
+
23
+ def emissions(frames: dict[int, dict[int, float]]) -> np.ndarray:
24
+ out = np.full((N_FRAMES, V), 1e-4)
25
+ out[1, K], out[12, T_] = .9, .9
26
+ for t, probs in frames.items():
27
+ for tok, p in probs.items():
28
+ out[t, tok] = p
29
+ for t in range(N_FRAMES):
30
+ out[t, BLANK] = 0.0
31
+ out[t, BLANK] = max(1e-4, 1.0 - out[t].sum())
32
+ return np.log(out / out.sum(1, keepdims=True))
33
+
34
+
35
+ @dataclass
36
+ class Scenario:
37
+ name: str
38
+ logp: np.ndarray
39
+ good: bool # a correct /a/ (should pass) or a substitution (should fail)
40
+ energy: np.ndarray = LOUD
41
+ spans: tuple = tuple(SPANS)
42
+
43
+
44
+ SCENARIOS = [
45
+ # correct /a/ over two frames, a one-frame /i/ spike in the quiet release before /t/
46
+ Scenario("spike in release", emissions({4: {A: .2}, 5: {A: .4}, 6: {A: .35}, 10: {I: .9}}), True),
47
+ # the same spike inside the loud nucleus: energy cannot tell it apart, the pooling has to
48
+ Scenario("spike in nucleus", emissions({4: {A: .2}, 5: {A: .4}, 6: {A: .35}, 8: {I: .9}}), True),
49
+ # a correct /a/ emitted on one frame, blanks everywhere else: nothing must dilute it
50
+ Scenario("peaky emission", emissions({5: {A: .92}}), True),
51
+ # the same with a weak schwa haze across the whole window
52
+ Scenario("peaky + haze", emissions({**{t: {SCHWA: .04} for t in range(2, 12)}, 5: {A: .92, SCHWA: .04}}), True),
53
+ # CTC put the emission a frame before the loud nucleus (alignment jitter)
54
+ Scenario("emission off nucleus", emissions({3: {A: .8}, 9: {I: .5}}), True,
55
+ energy=np.array([.2, .6, .5, .2, 1, 1, 1, 1, 1, .9, .15, .1, .4, .1]), spans=((1, 2), (3, 4), (12, 13))),
56
+ # /i/ said instead of /a/, held over three frames
57
+ Scenario("sustained substitution", emissions({4: {I: .6, A: .08}, 5: {I: .75, A: .1}, 6: {I: .6, A: .05}}), False),
58
+ # a short /i/ said instead of /a/: also a one-frame spike, but /a/ is absent
59
+ Scenario("one-frame substitution", emissions({5: {I: .85, A: .03}}), False),
60
+ ]
61
+
62
+
63
+ def score_a(sc: Scenario, cfg: GopConfig, energy: bool = True):
64
+ al = Alignment(0.0, list(sc.spans))
65
+ return score_tokens(sc.logp, al, TOKENS, BLANK, cfg, energy=sc.energy if energy else None)[1]
66
+
67
+
68
+ CONFIGS = {
69
+ "max": GopConfig(ratio_pool="max"),
70
+ "mean": GopConfig(ratio_pool="mean"),
71
+ "top2": GopConfig(ratio_pool="topk", pool_k=2),
72
+ "top3": GopConfig(ratio_pool="topk", pool_k=3),
73
+ "lse": GopConfig(ratio_pool="lse", pool_tau=0.5),
74
+ "energy": GopConfig(ratio_pool="energy"),
75
+ "gated": GopConfig(ratio_pool="gated"),
76
+ }
77
+
78
+
79
+ def naive_mean_score(sc: Scenario, cfg: GopConfig = GopConfig()) -> float:
80
+ """The diluting variant this design avoids: the mean posterior over the window used for the ratio AND the confidence."""
81
+ a, b = sc.spans[0][1], sc.spans[2][0]
82
+ p = np.exp(sc.logp[a:b]).mean(0)
83
+ comp = max(p[I], p[SCHWA])
84
+ return 100 * np.exp(cfg.leniency * (np.log(p[A]) - np.log(max(p[A], comp)))) * np.sqrt(min(1, p[A] / cfg.confident_peak))
85
+
86
+
87
+ # ---- the pooling operator ----
88
+
89
+ def test_pool_limits():
90
+ seg = np.log(np.array([[.9, .1], [.05, .1], [.05, .8]]))
91
+ assert np.allclose(pool_frames(seg, "max"), seg.max(0))
92
+ assert np.allclose(pool_frames(seg, "mean"), np.log(np.exp(seg).mean(0)))
93
+ assert np.allclose(pool_frames(seg, "topk", k=1), seg.max(0))
94
+ assert np.allclose(pool_frames(seg, "topk", k=3), pool_frames(seg, "mean"))
95
+ assert np.allclose(pool_frames(seg, "lse", tau=1.0), pool_frames(seg, "mean"))
96
+ assert np.allclose(pool_frames(seg, "lse", tau=0.02), seg.max(0), atol=0.03)
97
+ # a 0/1 weight is the mean over the kept frames
98
+ assert np.allclose(pool_frames(seg, "mean", weight=np.array([1, 0, 1])), np.log(np.exp(seg[[0, 2]]).mean(0)))
99
+
100
+
101
+ def test_mass_ratio_is_window_length_invariant():
102
+ """Padding the window with blank frames dilutes each pooled posterior but not their ratio
103
+ (up to the floor each padded frame adds to both masses)."""
104
+ seg = np.log(np.array([[.6, .3, .1], [.1, .2, .7]]))
105
+ pad = np.log(np.tile([[1e-4, 1e-4, 1 - 2e-4]], (20, 1)))
106
+ r = lambda s: pool_frames(s, "mean")[0] - pool_frames(s, "mean")[1]
107
+ assert r(np.vstack([seg, pad])) == pytest.approx(r(seg), abs=5e-3)
108
+
109
+
110
+ def test_spread_energy():
111
+ assert spread_energy(np.array([0, 0, 1., 0, 0]), 1).tolist() == [0, 1, 1, 1, 0]
112
+
113
+
114
+ # ---- the scenarios ----
115
+
116
+ def test_max_pool_is_the_previous_scorer():
117
+ """ratio_pool='max' is bit-for-bit the original formula."""
118
+ for sc in SCENARIOS:
119
+ s = score_a(sc, CONFIGS["max"])
120
+ a, b = s.window
121
+ comp_peak = sc.logp[a:b, [I, SCHWA]].max()
122
+ gop = s.peak - max(s.peak, comp_peak)
123
+ want = 100 * np.exp(0.5 * gop) * np.sqrt(min(1, np.exp(s.peak) / 0.30))
124
+ assert s.score == pytest.approx(want, abs=0.06), sc.name
125
+
126
+
127
+ @pytest.mark.parametrize("pool", ["top2", "mean", "energy", "gated"])
128
+ def test_spike_in_release_passes(pool):
129
+ sc = SCENARIOS[0]
130
+ assert score_a(sc, CONFIGS["max"]).score < 70 # the flaw
131
+ assert score_a(sc, CONFIGS[pool]).score >= 80
132
+
133
+
134
+ def test_lse_only_partly_absorbs_a_tall_spike():
135
+ """A power mean still favours one 0.9 frame over a 0.4/0.35/0.2 target; colder is closer to the max."""
136
+ sc = SCENARIOS[0]
137
+ hot, warm, cold = (score_a(sc, GopConfig(ratio_pool="lse", pool_tau=t)).score for t in (1.0, 0.5, 0.25))
138
+ assert score_a(sc, CONFIGS["max"]).score < cold < warm < hot
139
+
140
+
141
+ @pytest.mark.parametrize("pool", ["mean", "energy", "gated"])
142
+ def test_spike_in_nucleus_passes(pool):
143
+ """Energy cannot hide a spike on a loud frame; the mass ratio (the /a/ has more frames) still does."""
144
+ assert score_a(SCENARIOS[1], CONFIGS[pool]).score >= 80
145
+
146
+
147
+ def test_energy_weights_the_quiet_spike_down():
148
+ """Compared before the gop clips at 0: the pooled log ratio /a/ over /i/."""
149
+ sc = SCENARIOS[0]
150
+ a, b = score_a(sc, CONFIGS["max"]).window
151
+ seg = sc.logp[a:b]
152
+ margin = lambda w: float(np.subtract(*pool_frames(seg[:, [A, I]], "mean", weight=w)))
153
+ e0, e1 = sc.energy[a:b], spread_energy(sc.energy, 1)[a:b]
154
+ assert margin(energy_weights(e1, "energy")) > margin(None)
155
+ # the spread that forgives a jittered target emission also lifts a spike next to the nucleus over the gate
156
+ assert margin(energy_weights(e0, "gated")) > margin(energy_weights(e1, "energy")) > margin(energy_weights(e1, "gated"))
157
+
158
+
159
+ def test_long_pause_haze_needs_the_gate():
160
+ """A faint competitor over a long silent window adds up in a plain mean; the voice gate drops the silence."""
161
+ n = 30
162
+ p = np.full((n, V), 1e-4)
163
+ p[:, SCHWA] = .05
164
+ p[1, K], p[5, A], p[n - 2, T_] = .9, .9, .9
165
+ p[:, BLANK] = 0
166
+ p[:, BLANK] = 1 - p.sum(1)
167
+ energy = np.r_[[.3, .6, .5], np.ones(4), np.full(n - 9, .02), [.5, .1]]
168
+ al = Alignment(0.0, [(1, 2), (5, 6), (n - 2, n - 1)])
169
+ s = lambda cfg: score_tokens(np.log(p), al, TOKENS, BLANK, cfg, energy=energy)[1].score
170
+ assert s(CONFIGS["mean"]) < 85
171
+ assert s(CONFIGS["gated"]) >= 95 and s(CONFIGS["top2"]) >= 95
172
+
173
+
174
+ @pytest.mark.parametrize("pool", list(CONFIGS))
175
+ @pytest.mark.parametrize("name", ["peaky emission", "peaky + haze"])
176
+ def test_peaky_emission_is_not_diluted(pool, name):
177
+ sc = next(s for s in SCENARIOS if s.name == name)
178
+ assert score_a(sc, CONFIGS[pool]).score >= 95
179
+ assert naive_mean_score(sc) < 70 # the flaw: averaging the confidence too
180
+
181
+
182
+ @pytest.mark.parametrize("pool", list(CONFIGS))
183
+ def test_emission_off_the_nucleus_still_passes(pool):
184
+ sc = next(s for s in SCENARIOS if s.name == "emission off nucleus")
185
+ assert score_a(sc, CONFIGS[pool]).score >= 85
186
+
187
+
188
+ @pytest.mark.parametrize("pool", ["energy", "gated"])
189
+ def test_the_aligned_emission_is_never_gated_out(pool):
190
+ """Without the spread the emission frame is quiet, yet the alignment's own span keeps its full weight."""
191
+ sc = next(s for s in SCENARIOS if s.name == "emission off nucleus")
192
+ assert score_a(sc, replace(CONFIGS[pool], energy_spread=0, energy_gate=0.9)).score >= 85
193
+
194
+
195
+ @pytest.mark.parametrize("pool", list(CONFIGS))
196
+ @pytest.mark.parametrize("name", ["sustained substitution", "one-frame substitution"])
197
+ def test_substitution_fails(pool, name):
198
+ sc = next(s for s in SCENARIOS if s.name == name)
199
+ s = score_a(sc, CONFIGS[pool])
200
+ assert s.score < 40
201
+ assert s.competitor == I
202
+
203
+
204
+ @pytest.mark.parametrize("pool", list(CONFIGS))
205
+ def test_energy_pools_without_audio_fall_back_to_mean(pool):
206
+ sc = SCENARIOS[0]
207
+ s = score_a(sc, CONFIGS[pool], energy=False)
208
+ want = CONFIGS["mean"] if pool in ("energy", "gated") else CONFIGS[pool]
209
+ assert s.score == score_a(sc, want, energy=False).score
210
+
211
+
212
+ def test_unpooled_tokens_keep_the_max():
213
+ sc = SCENARIOS[0]
214
+ al = Alignment(0.0, list(sc.spans))
215
+ on = score_tokens(sc.logp, al, TOKENS, BLANK, CONFIGS["mean"], pooled=[False, True, False])[1]
216
+ off = score_tokens(sc.logp, al, TOKENS, BLANK, CONFIGS["mean"], pooled=[True, False, True])[1]
217
+ assert on.score > off.score == score_a(sc, CONFIGS["max"]).score
218
+
219
+
220
+ def test_peak_and_deletion_do_not_depend_on_the_pool():
221
+ for sc in SCENARIOS:
222
+ ref = score_a(sc, CONFIGS["max"])
223
+ for pool in POOLS[1:]:
224
+ s = score_a(sc, GopConfig(ratio_pool=pool))
225
+ assert (s.peak, s.deleted, s.window) == (ref.peak, ref.deleted, ref.window)
226
+
227
+
228
+ # ---- GOP_RATIO_POOL ----
229
+
230
+ def test_env_picks_the_pool(monkeypatch):
231
+ monkeypatch.delenv("GOP_RATIO_POOL", raising=False)
232
+ assert GopConfig().ratio_pool == "gated"
233
+ monkeypatch.setenv("GOP_RATIO_POOL", "")
234
+ assert GopConfig().ratio_pool == "gated"
235
+ monkeypatch.setenv("GOP_RATIO_POOL", " Max ")
236
+ assert GopConfig().ratio_pool == "max"
237
+ monkeypatch.setenv("GOP_RATIO_POOL", "maxx")
238
+ assert GopConfig().ratio_pool == "gated" # a typo is logged, not a crash
239
+ assert GopConfig(ratio_pool="topk").ratio_pool == "topk" # an explicit config wins over the env
tests/test_gop_window.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Class-based GOP windows (align.compute_token_gop): a consonant reads its competitors from the wide window, a vowel
2
+ from its aligned span +- GopConfig.vowel_window frames. The spans come from ctc_viterbi, as in the scorer.
3
+
4
+ Every frame is a distribution: the named sounds get their probability, a 1e-4 floor the rest, blank what is left.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+ import numpy as np
11
+ import pytest
12
+
13
+ from app.align import GopConfig, compute_token_gop, ctc_viterbi, score_tokens, token_window
14
+
15
+ BLANK, K, A, I, T_, Q, J = 0, 1, 2, 3, 4, 5, 6
16
+ V = 7
17
+ NAMES = {BLANK: "_", K: "k", A: "a", I: "i", T_: "t", Q: "q", J: "j"}
18
+
19
+
20
+ def emissions(n: int, frames: dict[int, dict[int, float]]) -> np.ndarray:
21
+ out = np.full((n, V), 1e-4)
22
+ for t, probs in frames.items():
23
+ for tok, p in probs.items():
24
+ out[t, tok] = p
25
+ out[:, BLANK] = 0.0
26
+ out[:, BLANK] = np.maximum(1e-4, 1.0 - out.sum(1))
27
+ return np.log(out / out.sum(1, keepdims=True))
28
+
29
+
30
+ @dataclass
31
+ class Case:
32
+ name: str
33
+ tokens: list[int]
34
+ frames: dict[int, dict[int, float]]
35
+ target: int # the index of the token under test
36
+ good: bool # said right (should pass) or not (should fail)
37
+ n: int = 14
38
+ edges: list = field(default_factory=list)
39
+
40
+ @property
41
+ def logp(self):
42
+ return emissions(self.n, self.frames)
43
+
44
+ @property
45
+ def vowels(self):
46
+ return [t in (A, I) for t in self.tokens]
47
+
48
+
49
+ CASES = [
50
+ # /a/ over frames 4-6, a one-frame /i/ spike in the transition into /t/
51
+ Case("vowel: spike in transition", [K, A, T_],
52
+ {1: {K: .9}, 4: {A: .3}, 5: {A: .5}, 6: {A: .4}, 9: {I: .9}, 12: {T_: .9}}, 1, True),
53
+ # /i/ said for /a/, held across the nucleus
54
+ Case("vowel: sustained substitution", [K, A, T_],
55
+ {1: {K: .9}, 4: {I: .6, A: .08}, 5: {I: .75, A: .1}, 6: {I: .6, A: .05}, 12: {T_: .9}}, 1, False),
56
+ # /a/ present but weak, /i/ held two frames later, outside the narrow window: what the narrow window gives up
57
+ Case("vowel: displaced substitution", [K, A, T_],
58
+ {1: {K: .9}, 4: {A: .35}, 7: {I: .8}, 8: {I: .85}, 12: {T_: .9}}, 1, False),
59
+ # /k/ barely there, the /q/ said instead peaking in the blanks before it
60
+ Case("consonant: competitor in blanks", [A, K, A],
61
+ {2: {A: .9}, 4: {Q: .85}, 5: {Q: .5}, 7: {K: .08}, 11: {A: .9}}, 1, False),
62
+ # /a/ deleted: the alignment gives it one frame it barely has
63
+ Case("vowel: deleted", [K, A, T_], {1: {K: .9}, 6: {A: .02}, 12: {T_: .9}}, 1, False),
64
+ # /ja/: the glide's /i/ colour lingers into the frame before the vowel
65
+ Case("vowel: glide transition", [J, A, T_],
66
+ {1: {J: .9}, 3: {I: .8}, 4: {I: .8}, 5: {A: .45}, 6: {A: .4}, 12: {T_: .9}}, 1, True,
67
+ edges=[None, ({I}, set()), None]),
68
+ # /ji/ said for /ja/: the vowel's own frames are /i/, so the glide does not excuse it
69
+ Case("vowel: substitution beside glide", [J, A, T_],
70
+ {1: {J: .9}, 5: {I: .7, A: .05}, 6: {I: .7, A: .05}, 7: {I: .6}, 12: {T_: .9}}, 1, False,
71
+ edges=[None, ({I}, set()), None]),
72
+ ]
73
+
74
+ WIDE, NARROW = GopConfig(ratio_pool="max", vowel_window=None), GopConfig(ratio_pool="max", vowel_window=1)
75
+
76
+
77
+ def case(name: str) -> Case:
78
+ return next(c for c in CASES if c.name == name)
79
+
80
+
81
+ def score(c: Case, cfg: GopConfig, edges: bool = True):
82
+ al = ctc_viterbi(c.logp, c.tokens, BLANK)
83
+ return score_tokens(c.logp, al, c.tokens, BLANK, cfg, vowels=c.vowels,
84
+ edges=c.edges if edges and c.edges else None)[c.target]
85
+
86
+
87
+ def test_windows():
88
+ spans = [(1, 2), (5, 6), (12, 13)]
89
+ assert token_window(spans, 1, 14) == (2, 12)
90
+ assert token_window(spans, 1, 14, 1) == (4, 7)
91
+ assert token_window(spans, 1, 14, 0) == (5, 6)
92
+ assert token_window(spans, 1, 14, 9) == (2, 12) # never past the neighbours
93
+ assert token_window(spans, 0, 14, 1) == (0, 3)
94
+
95
+
96
+ def test_wide_vowel_window_is_the_previous_scorer():
97
+ """vowel_window None reads a vowel exactly as score_tokens did before it had classes."""
98
+ for c in CASES:
99
+ al = ctc_viterbi(c.logp, c.tokens, BLANK)
100
+ old = score_tokens(c.logp, al, c.tokens, BLANK, WIDE)
101
+ assert score_tokens(c.logp, al, c.tokens, BLANK, WIDE, vowels=c.vowels) == old, c.name
102
+
103
+
104
+ def test_narrow_window_drops_a_transition_spike():
105
+ c = case("vowel: spike in transition")
106
+ assert score(c, WIDE).score < 80 and score(c, WIDE).competitor == I
107
+ assert score(c, NARROW).score >= 95
108
+
109
+
110
+ def test_sustained_vowel_substitution_still_fails():
111
+ s = score(case("vowel: sustained substitution"), NARROW)
112
+ assert s.score < 30 and s.competitor == I
113
+
114
+
115
+ def test_narrow_window_gives_up_a_displaced_substitution():
116
+ """The cost: a target that is present, with the real vowel held outside span +- pad, now passes."""
117
+ c = case("vowel: displaced substitution")
118
+ assert score(c, WIDE).score < 70
119
+ assert score(c, NARROW).score >= 95
120
+ assert score(c, GopConfig(ratio_pool="max", vowel_window=3)).score < 70 # a wider pad catches it again
121
+
122
+
123
+ def test_wide_pooled_ratio_gets_both():
124
+ """Why wide is the default: a pooled ratio passes the one-frame spike and still fails the held substitution."""
125
+ cfg = GopConfig(ratio_pool="mean", vowel_window=None)
126
+ assert score(case("vowel: spike in transition"), cfg).score >= 95
127
+ assert score(case("vowel: displaced substitution"), cfg).score < 50
128
+
129
+
130
+ def test_consonant_keeps_the_wide_window():
131
+ c = case("consonant: competitor in blanks")
132
+ for cfg in (WIDE, NARROW):
133
+ s = score(c, cfg)
134
+ assert s.score < 50 and s.competitor == Q
135
+ # read as a vowel (narrow), the /q/ in the blanks is lost
136
+ al = ctc_viterbi(c.logp, c.tokens, BLANK)
137
+ narrow = compute_token_gop(c.logp, al.spans, 1, c.tokens, BLANK, NARROW, vowel=True)
138
+ assert narrow.competitor != Q
139
+
140
+
141
+ @pytest.mark.parametrize("pad", [None, 0, 1, 2])
142
+ def test_deletion_is_read_from_the_wide_window(pad):
143
+ c = case("vowel: deleted")
144
+ al = ctc_viterbi(c.logp, c.tokens, BLANK)
145
+ assert al.spans[1][1] - al.spans[1][0] == 1 # one frame
146
+ s = score(c, GopConfig(ratio_pool="max", vowel_window=pad))
147
+ assert s.deleted and s.score < 30
148
+
149
+
150
+ def test_a_competitor_outside_the_narrow_window_still_blocks_deletion():
151
+ """Not deleted but substituted: the /i/ is strong in the wide window, if out of the narrow one."""
152
+ c = Case("x", [K, A, T_], {1: {K: .9}, 6: {A: .02}, 9: {I: .9}, 12: {T_: .9}}, 1, False)
153
+ assert not score(c, NARROW).deleted
154
+
155
+
156
+ def test_glide_colour_is_not_a_competitor():
157
+ c = case("vowel: glide transition")
158
+ assert score(c, NARROW, edges=False).score < 85
159
+ assert score(c, NARROW).score >= 95
160
+ assert score(c, WIDE).score >= 95 # the mask works in the wide window too
161
+
162
+
163
+ def test_glide_does_not_excuse_a_substitution():
164
+ s = score(case("vowel: substitution beside glide"), NARROW)
165
+ assert s.score < 30 and s.competitor == I
166
+
167
+
168
+ def test_glide_colour_does_not_block_deletion():
169
+ c = Case("x", [J, A, T_], {1: {J: .9}, 3: {I: .3}, 6: {A: .02}, 12: {T_: .9}}, 1, False,
170
+ edges=[None, ({I}, set()), None])
171
+ assert score(c, NARROW, edges=False).deleted is False
172
+ assert score(c, NARROW).deleted
173
+
174
+
175
+ def test_env_picks_the_vowel_window(monkeypatch):
176
+ for raw, want in (("", None), ("wide", None), (" Wide ", None), ("narrow", 1), ("0", 0), ("3", 3), ("tight", None)):
177
+ monkeypatch.setenv("GOP_VOWEL_WINDOW", raw)
178
+ assert GopConfig().vowel_window == want, raw
179
+ monkeypatch.delenv("GOP_VOWEL_WINDOW")
180
+ assert GopConfig().vowel_window is None
181
+
182
+
183
+ def test_scorer_marks_glides_beside_vowels():
184
+ from types import SimpleNamespace
185
+
186
+ from app.scorer import Scorer, TargetToken
187
+
188
+ ids = {"j": 1, "a": 2, "w": 3, "i": 4, "iː": 5, "u": 6} # no e, eː, uː, o, oː in this model
189
+ fake = SimpleNamespace(vocab=SimpleNamespace(sound_id=ids.get))
190
+ toks = [TargetToken(p, ids[p], 0) for p in ("j", "a", "w", "a")]
191
+ assert Scorer._glide_edges(fake, toks) == [None, ({4, 5}, {6}), None, ({6}, set())]
tests/test_lab.py CHANGED
@@ -72,6 +72,30 @@ class FakeAuth:
72
  def lab_audio(self, sample_id):
73
  return (tone_wav(), "audio/wav") if self.lab_sample(sample_id) else None
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  def test_items_are_well_formed():
77
  assert len({i["id"] for i in ITEMS}) == len(ITEMS)
@@ -189,6 +213,29 @@ def test_lab_results_and_flag_rescore():
189
  assert c.post("/api/lab/flags/8/rescore", headers=teacher).status_code == 404
190
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  def test_lab_rescores_its_recordings_and_compares_with_the_stored_scores():
193
  from fastapi.testclient import TestClient
194
 
@@ -206,6 +253,7 @@ def test_lab_rescores_its_recordings_and_compares_with_the_stored_scores():
206
  assert r["id"] == 1 and not any(p["flagged"] for w in r["result"]["words"] for p in w["phonemes"])
207
  assert "trace" not in r["result"]
208
  d = c.post("/api/lab/results", json={"results": {"1": r["result"]}}, headers=teacher).json()
 
209
  before, after = ({x["sound"]: x for x in d[k]["sounds"]}["ħ"] for k in ("stored", "now"))
210
  assert before["frr"] == 1.0 and after["frr"] == 0.0
211
  assert c.post("/api/lab/samples/9/rescore", headers=teacher).status_code == 404
@@ -253,3 +301,137 @@ def test_lab_automatic_test_with_tts():
253
  c.app.state.tts = None
254
  assert c.get("/api/lab/tts", headers=teacher).json()["voices"] == []
255
  assert c.post("/api/lab/tts", data={"item": "pair-ħaal", "voice": "ראאף"}, headers=teacher).status_code == 503
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  def lab_audio(self, sample_id):
73
  return (tone_wav(), "audio/wav") if self.lab_sample(sample_id) else None
74
 
75
+ def delete_lab_samples(self, speaker=None):
76
+ gone = [s for s in self.samples if speaker is None or (s.get("speaker") or "") == speaker]
77
+ self.samples = [s for s in self.samples if s not in gone]
78
+ return len(gone)
79
+
80
+
81
+ def test_lab_deletes_one_speaker_or_everyone():
82
+ from fastapi.testclient import TestClient
83
+
84
+ from app.main import create_app
85
+
86
+ auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
87
+ audio = {"audio": ("a.wav", tone_wav(), "audio/wav")}
88
+ with TestClient(create_app(scorer_for(emissions(["ħ", "aː", "l"])), NoTTS(), auth=auth)) as c:
89
+ for speaker in ("א", "א", "ב"):
90
+ c.post("/api/lab/score", data={"item": "pair-ħaal", "speaker": speaker}, files=audio, headers=teacher)
91
+ assert c.delete("/api/lab/samples", headers=teacher).status_code == 400 # never all by omission
92
+ assert c.delete("/api/lab/samples?speaker=א&everyone=true", headers=teacher).status_code == 400
93
+ assert c.delete("/api/lab/samples?everyone=true", headers={"Authorization": "Bearer student"}).status_code == 403
94
+ assert c.delete("/api/lab/samples?speaker=%20א%20", headers=teacher).json() == {"deleted": 2}
95
+ assert c.get("/api/lab/results", headers=teacher).json()["speakers"] == ["ב"]
96
+ assert c.delete("/api/lab/samples?everyone=true", headers=teacher).json() == {"deleted": 1}
97
+ assert c.get("/api/lab/results", headers=teacher).json()["totals"]["recordings"] == 0
98
+
99
 
100
  def test_items_are_well_formed():
101
  assert len({i["id"] for i in ITEMS}) == len(ITEMS)
 
213
  assert c.post("/api/lab/flags/8/rescore", headers=teacher).status_code == 404
214
 
215
 
216
+ def test_minerva_lab_groups_imported_segments_by_video():
217
+ from fastapi.testclient import TestClient
218
+
219
+ from app.main import create_app
220
+
221
+ auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
222
+ auth.samples = [
223
+ {"id": 2, "item": "minerva:U8aBRd3-wlM:02", "speaker": "מינרווה", "device": "minerva:U8aBRd3-wlM:4.2-7.8",
224
+ "ar": "مرحبا", "ipa": "m a r ħ a b a", "score": 81, "heard_ipa": "m a r ħ a b a", "flagged": [],
225
+ "result": {}, "audio": "two.webm"},
226
+ {"id": 1, "item": "minerva:U8aBRd3-wlM:01", "speaker": "מינרווה", "device": "minerva:U8aBRd3-wlM:1.0-4.0",
227
+ "ar": "أهلا", "ipa": "ʔ a h l a", "score": 70, "heard_ipa": "ʔ a h l a", "flagged": ["h>x"],
228
+ "result": {"unrelated": False}, "audio": "one.webm"},
229
+ {"id": 3, "item": "pair-ħaal", "speaker": "אחר", "device": "", "audio": "other.wav"},
230
+ ]
231
+ with TestClient(create_app(scorer_for(emissions(["ħ", "aː", "l"])), NoTTS(), auth=auth)) as c:
232
+ d = c.get("/api/lab/minerva", headers=teacher).json()
233
+ assert len(d["videos"]) == 1 and d["videos"][0]["id"] == "U8aBRd3-wlM"
234
+ assert [s["line"] for s in d["videos"][0]["samples"]] == [1, 2]
235
+ assert d["videos"][0]["samples"][0]["start"] == 1.0
236
+ assert c.get("/api/lab/minerva", headers={"Authorization": "Bearer student"}).status_code == 403
237
+
238
+
239
  def test_lab_rescores_its_recordings_and_compares_with_the_stored_scores():
240
  from fastapi.testclient import TestClient
241
 
 
253
  assert r["id"] == 1 and not any(p["flagged"] for w in r["result"]["words"] for p in w["phonemes"])
254
  assert "trace" not in r["result"]
255
  d = c.post("/api/lab/results", json={"results": {"1": r["result"]}}, headers=teacher).json()
256
+ assert d["pool"] == c.app.state.scorer.cfg.gop.ratio_pool
257
  before, after = ({x["sound"]: x for x in d[k]["sounds"]}["ħ"] for k in ("stored", "now"))
258
  assert before["frr"] == 1.0 and after["frr"] == 0.0
259
  assert c.post("/api/lab/samples/9/rescore", headers=teacher).status_code == 404
 
301
  c.app.state.tts = None
302
  assert c.get("/api/lab/tts", headers=teacher).json()["voices"] == []
303
  assert c.post("/api/lab/tts", data={"item": "pair-ħaal", "voice": "ראאף"}, headers=teacher).status_code == 503
304
+
305
+
306
+ def test_lab_sweeps_every_gop_setting():
307
+ from fastapi.testclient import TestClient
308
+
309
+ from app import lab
310
+ from app.main import create_app
311
+
312
+ assert len(lab.SWEEP) == 24 and len(lab.SWEEP_KEYS) == 24
313
+ assert lab.quality({"frr": .1, "detection": .5}) == 70.0 and lab.quality({"frr": .1, "detection": None}) == 90.0
314
+ assert lab.quality({"frr": None}) is None and lab.quality_partial({"frr": .1, "detection": None})
315
+
316
+ auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
317
+ audio = {"audio": ("a.wav", tone_wav(), "audio/wav")}
318
+ with TestClient(create_app(scorer_for(emissions(["χ", "aː", "l"])), NoTTS(), auth=auth)) as c:
319
+ c.post("/api/lab/score", data={"item": "pair-ħaal"}, files=audio, headers=teacher) # ħ heard as χ
320
+ c.post("/api/lab/score", data={"item": "pair-ħaal", "mistake": "0"}, files=audio,
321
+ headers=teacher)
322
+ before = c.app.state.scorer.cfg.gop
323
+ r = c.post("/api/lab/samples/1/sweep", headers=teacher).json()
324
+ assert r["id"] == 1 and set(r["results"]) == set(lab.SWEEP_KEYS)
325
+ assert c.app.state.scorer.cfg.gop == before # the server's own setting is untouched
326
+ r2 = c.post("/api/lab/samples/2/sweep", headers=teacher).json()
327
+ d = c.post("/api/lab/sweep", json={"results": {"1": r["results"], "2": r2["results"]}}, headers=teacher).json()
328
+ assert len(d["settings"]) == 24 and d["recordings"] == 2
329
+ assert d["current"] == lab.sweep_key(before.ratio_pool, before.vowel_window)
330
+ s = d["settings"][0]
331
+ assert {"key", "pool", "window", "quality", "partial", "totals"} <= set(s)
332
+ qs = [x["quality"] for x in d["settings"]]
333
+ assert qs == sorted(qs, reverse=True)
334
+ assert c.post("/api/lab/samples/9/sweep", headers=teacher).status_code == 404
335
+ assert c.post("/api/lab/sweep", json={"results": []}, headers=teacher).status_code == 400
336
+ assert c.post("/api/lab/samples/1/sweep", headers={"Authorization": "Bearer student"}).status_code == 403
337
+
338
+
339
+ def test_vowel_stats_score_vowels_not_flags():
340
+ from app.lab import vowel_stats
341
+
342
+ def sample(i, phones, mistake=None):
343
+ return {"id": i, "label": "mistake" if mistake else "correct",
344
+ "mistake": {"index": 0, "expected": mistake} if mistake else None,
345
+ "result": {"words": [{"phonemes": [{"ipa": p, "score": sc} for p, sc in phones]}]}}
346
+
347
+ rows = [sample(1, [("k", 40), ("a", 95), ("i", 70)]), sample(2, [("a", 30), ("l", 100)], mistake="a"),
348
+ {**sample(3, [("a", 10)]), "result": {"unrelated": True, "words": [{"phonemes": [{"ipa": "a", "score": 10}]}]}}]
349
+ v = vowel_stats(rows)
350
+ assert v == {"right": 2, "mean": 82.5, "weak": 0.5, "planted": 1, "auc": 1.0} # k is no vowel, #3 is unrelated
351
+ assert vowel_stats(rows[:1])["auc"] is None
352
+ assert vowel_stats(rows, {2: {"words": [{"phonemes": [{"ipa": "a", "score": 70}]}]}})["auc"] == 0.75
353
+
354
+
355
+ def test_the_sweep_scores_every_setting_whatever_the_env(monkeypatch):
356
+ """The sweep passes each setting to the scorer itself: the env only picks the server's own one."""
357
+ from fastapi.testclient import TestClient
358
+
359
+ import app.scorer as scorer_module
360
+ from app import lab
361
+ from app.main import create_app
362
+
363
+ monkeypatch.setenv("GOP_RATIO_POOL", "max")
364
+ monkeypatch.setenv("GOP_VOWEL_WINDOW", "wide")
365
+ seen, real = [], scorer_module.score_tokens
366
+ monkeypatch.setattr(scorer_module, "score_tokens",
367
+ lambda logp, al, ids, blank, cfg, *a, **k: seen.append((cfg.ratio_pool, cfg.vowel_window))
368
+ or real(logp, al, ids, blank, cfg, *a, **k))
369
+ auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
370
+ with TestClient(create_app(scorer_for(emissions(["ħ", "aː", "l"])), NoTTS(), auth=auth)) as c:
371
+ c.post("/api/lab/score", data={"item": "pair-ħaal"}, files={"audio": ("a.wav", tone_wav(), "audio/wav")},
372
+ headers=teacher)
373
+ assert c.app.state.scorer.cfg.gop.ratio_pool == "max"
374
+ seen.clear()
375
+ r = c.post("/api/lab/samples/1/sweep", headers=teacher).json()
376
+ assert sorted(seen, key=str) == sorted(lab.SWEEP, key=str)
377
+ d = c.post("/api/lab/sweep", json={"results": {"1": r["results"]}}, headers=teacher).json()
378
+ assert all({"right", "mean", "weak", "planted", "auc"} <= set(s["vowels"]) for s in d["settings"])
379
+ base = next(s for s in d["settings"] if s["key"] == "max|wide")
380
+ assert base["changed"] == 0 and base["sounds"] == 3
381
+ assert d["current"] == "max|wide"
382
+
383
+
384
+ def test_the_sweep_shows_which_settings_changed_a_score():
385
+ """A long vowel with an /i/ spike inside its window: max|wide marks it down, the pooled settings do not."""
386
+ from fastapi.testclient import TestClient
387
+
388
+ from app.main import create_app
389
+ from test_scoring import SYMS, VOCAB
390
+
391
+ lp = emissions(["ħ", "aː", "l"], gap=5)
392
+ p = np.exp(lp)
393
+ vowel = [t for t in range(len(p)) if lp[t].argmax() == VOCAB["aː"]]
394
+ for t in vowel:
395
+ p[t] = (1 - .45) / (len(SYMS) - 1); p[t, VOCAB["aː"]] = .45
396
+ p[vowel[-1] + 3] = .1 / (len(SYMS) - 1); p[vowel[-1] + 3, VOCAB["i"]] = .9
397
+ auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
398
+ with TestClient(create_app(scorer_for(np.log(p / p.sum(1, keepdims=True))), NoTTS(), auth=auth)) as c:
399
+ c.post("/api/lab/score", data={"item": "pair-ħaal"}, files={"audio": ("a.wav", tone_wav(), "audio/wav")},
400
+ headers=teacher)
401
+ r = c.post("/api/lab/samples/1/sweep", headers=teacher).json()
402
+ d = {s["key"]: s for s in c.post("/api/lab/sweep", json={"results": {"1": r["results"]}}, headers=teacher).json()["settings"]}
403
+ assert d["max|wide"]["changed"] == 0 and d["max|wide"]["vowels"]["mean"] < 80
404
+ for key in ("mean|wide", "gated|wide", "topk|wide", "max|1"):
405
+ assert d[key]["changed"] == 1 and d[key]["vowels"]["mean"] > 95, key
406
+
407
+
408
+ def test_lab_opens_the_trace_of_any_saved_recording():
409
+ from fastapi.testclient import TestClient
410
+
411
+ from app.main import create_app
412
+
413
+ auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
414
+ audio = {"audio": ("a.wav", tone_wav(), "audio/wav")}
415
+ with TestClient(create_app(scorer_for(emissions(["χ", "aː", "l"])), NoTTS(), auth=auth)) as c:
416
+ c.post("/api/lab/score", data={"item": "pair-ħaal", "speaker": "א"}, files=audio, headers=teacher)
417
+ recs = c.get("/api/lab/results", headers=teacher).json()["recordings"]
418
+ assert [(r["id"], r["item"], r["speaker"], r["flagged"]) for r in recs] == [(1, "pair-ħaal", "א", ["ħ>χ"])]
419
+ assert recs[0]["unrelated"] is False
420
+ d = c.post("/api/lab/samples/1/rescore?trace=true", headers=teacher).json()
421
+ assert "trace" in d["result"] and d["result"]["trace"]["tokens"] and d["sample"]["item"] == "pair-ħaal"
422
+ g = c.app.state.scorer.cfg.gop
423
+ assert d["setting"] == f"{g.ratio_pool}|{'wide' if g.vowel_window is None else g.vowel_window}"
424
+ d = c.post("/api/lab/samples/1/rescore?trace=true&setting=max|1", headers=teacher).json()
425
+ assert d["setting"] == "max|1" and "trace" in d["result"]
426
+ assert "trace" not in c.post("/api/lab/samples/1/rescore", headers=teacher).json()["result"]
427
+ assert c.post("/api/lab/samples/1/rescore?trace=true&setting=nope", headers=teacher).status_code == 400
428
+
429
+
430
+ def test_imported_lab_line_reconstructs_its_phrase_outside_the_fixed_list():
431
+ from app.lab import sample_phrase
432
+
433
+ phrase = sample_phrase({"id": 7, "item": "minerva:video:01", "ar": "حال", "ipa": "ħ aː l",
434
+ "speaker": "מינרווה"})
435
+ assert phrase == {"cat": "lab", "key": "minerva:video:01", "he": "מינרווה",
436
+ "variants": [("حال", "ħ aː l")]}
437
+ assert sample_phrase({"id": 8, "item": "removed-item"}) is None
tests/test_minerva_import.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from scripts.import_minerva import PILOT, phrases
2
+
3
+
4
+ def test_minerva_pilot_manifest_has_exactly_the_reviewed_three_videos_and_70_lines():
5
+ assert [v.youtube_id for v in PILOT] == ["U8aBRd3-wlM", "OuAwxXUZBU4", "V2y6LqnoXTo"]
6
+ assert [len(v.lines) for v in PILOT] == [15, 22, 33]
7
+ made, _ = phrases()
8
+ assert len(made) == 70
9
+ assert len({p[4]["key"] for p in made}) == 70
10
+ assert all(p[2] < p[3] and p[4]["variants"] for p in made)