mathly / app /reference.py
3v324v23's picture
Add Arabic diacritics module and related tests
ccffcbd
Raw History Blame Contribute Delete
4.18 kB
"""Native baseline for the scores: the playback voices saying the phrase, scored by the same model.
The phoneme model mishears some sounds even from a native (ʕ in على as dropped, l as r), and a
learner should not be marked down or get a tip for those. So each practised phrase is also scored
on its TTS model answer, and Scorer measures the learner against that (see Scorer.score_logp).
One voice is generated per phrase (the first of the list); the other voices are used only when
the page has already played them, so calibration costs at most one TTS call per phrase.
References are built in the background and kept in memory; a score waits a few seconds for them
and is given without calibration if they are not ready by then.
"""
from __future__ import annotations
import logging
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor, wait
from .arabic_diacritics import point_arabic
from .scorer import Reference, Scorer, decode_audio
log = logging.getLogger("ihki.reference")
class References:
def __init__(self, scorer: Scorer, tts, voices: list[str], wait_s: float = 8.0, retry_s: float = 300.0):
self.scorer, self.tts, self.voices = scorer, tts, voices
self.wait_s, self.retry_s = wait_s, retry_s
self._pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ihki-ref")
self._jobs: dict[tuple[int, str], Future] = {}
self._retry_at: dict[tuple[int, str], float] = {} # failed builds are not retried before this time
self._lock = threading.Lock()
def _build(self, phrase: dict, text: str, voice: str) -> Reference | None:
data, _ = self.tts.wav(text, voice)
ref = self.scorer.reference(decode_audio(data), phrase)
if ref is None:
log.info("reference not usable: %r by voice %s", text, voice)
return ref
def get(self, phrase_id: int, phrase: dict) -> list[Reference]:
"""The references of a phrase that are ready within the wait."""
text = point_arabic(*phrase["variants"][0])
jobs: list[tuple[tuple[int, str], Future]] = []
with self._lock:
for n, voice in enumerate(self.voices):
key = (phrase_id, voice)
job = self._jobs.get(key)
if (job is None and time.monotonic() >= self._retry_at.get(key, 0)
and (n == 0 or self.tts.cached(text, voice))):
job = self._jobs[key] = self._pool.submit(self._build, phrase, text, voice)
if job is not None:
jobs.append((key, job))
done, _ = wait([job for _, job in jobs], timeout=self.wait_s)
refs = []
for key, job in jobs:
if job not in done:
continue
try:
ref = job.result()
except Exception as e: # provider down, undecodable audio: score without this voice
log.warning("reference for phrase %s failed: %s", key[0], e)
with self._lock:
if self._jobs.get(key) is job:
del self._jobs[key]
self._retry_at[key] = time.monotonic() + self.retry_s
continue
if ref is not None:
refs.append(ref)
return refs
def invalidate(self, phrase_id: int | None = None, voice: str | None = None) -> int:
"""Forget matching in-memory baselines so the next score rebuilds them from the current WAV."""
with self._lock:
keys = [key for key in self._jobs
if (phrase_id is None or key[0] == phrase_id) and (voice is None or key[1] == voice)]
for key in keys:
job = self._jobs.pop(key)
job.cancel() # harmless when generation or analysis has already started
self._retry_at.pop(key, None)
retry_keys = [key for key in self._retry_at
if (phrase_id is None or key[0] == phrase_id) and (voice is None or key[1] == voice)]
for key in retry_keys:
self._retry_at.pop(key, None)
return len(keys)