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 +20 -2
- app/align.py +176 -39
- app/auth.py +28 -1
- app/lab.py +93 -2
- app/main.py +189 -9
- app/scorer.py +24 -2
- data/minerva_metadata.json +114 -0
- docs/gop-pooling.md +158 -0
- scripts/bench.py +2 -3
- scripts/gop_pooling_bench.py +68 -0
- scripts/gop_window_bench.py +53 -0
- scripts/import_minerva.py +243 -0
- static/index.html +316 -14
- tests/test_gop_pooling.py +239 -0
- tests/test_gop_window.py +191 -0
- tests/test_lab.py +182 -0
- tests/test_minerva_import.py +10 -0
.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:
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 =
|
| 94 |
-
n_real: int | None = None, named: set[int] | None = None
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 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"), "
|
|
|
|
| 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,
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 803 |
-
|
| 804 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 78 |
-
if
|
| 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(
|
| 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
|
| 5175 |
else drawLabBench(labNow.now,labNow.stored,"לעומת הציונים שנשמרו בזמן ההקלטה");
|
| 5176 |
-
$("labResState").textContent=`נותחו מחדש ${Object.keys(results).length} הקלטות בגרסה ${labNow
|
| 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
|
| 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
|
| 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)
|