Add Arabic diacritics module and related tests
Browse files- Implemented `arabic_diacritics.py` to add Arabic vowel marks to words based on their IPA pronunciation.
- Created tests for the diacritics functionality in `test_arabic_diacritics.py`, covering various cases including short and long vowels, gemination, and phrase handling.
- Updated grading pipeline documentation to include detailed explanations of the scoring process and audio handling.
- .env.example +0 -1
- README.md +2 -2
- app/arabic_diacritics.py +106 -0
- app/main.py +25 -14
- app/phrases.py +2 -0
- app/reference.py +17 -1
- app/tts.py +50 -46
- docs/grading-pipeline.md +494 -0
- static/guide.html +2 -2
- static/index.html +47 -24
- tests/test_arabic_diacritics.py +103 -0
- tests/test_scoring.py +13 -10
.env.example
CHANGED
|
@@ -7,7 +7,6 @@ SAWTAK_API_KEY=
|
|
| 7 |
|
| 8 |
# Optional: a specific voice id from the Sawtak catalog.
|
| 9 |
# Empty = the most liked ready Palestinian voice.
|
| 10 |
-
SAWTAK_VOICE_ID=
|
| 11 |
|
| 12 |
# Google sign-in through Supabase (Project Settings -> API Keys). Both values are public: the page gets them too.
|
| 13 |
# Empty = no sign-in, the service is open. On Hugging Face these can be plain Space *variables*.
|
|
|
|
| 7 |
|
| 8 |
# Optional: a specific voice id from the Sawtak catalog.
|
| 9 |
# Empty = the most liked ready Palestinian voice.
|
|
|
|
| 10 |
|
| 11 |
# Google sign-in through Supabase (Project Settings -> API Keys). Both values are public: the page gets them too.
|
| 12 |
# Empty = no sign-in, the service is open. On Hugging Face these can be plain Space *variables*.
|
README.md
CHANGED
|
@@ -488,6 +488,6 @@ Tune them on a handful of labelled learner recordings. A useful target is that n
|
|
| 488 |
|
| 489 |
- Local: `cp .env.example .env` and set `SAWTAK_API_KEY` (`.env` is git-ignored and excluded from the Docker image).
|
| 490 |
- Hugging Face: add `SAWTAK_API_KEY` as a Space **secret** (Settings → Variables and secrets). It is injected as an environment variable at runtime.
|
| 491 |
-
-
|
| 492 |
- Without a key the endpoint answers 503 and the page falls back to the device's Arabic voice.
|
| 493 |
-
- The same
|
|
|
|
| 488 |
|
| 489 |
- Local: `cp .env.example .env` and set `SAWTAK_API_KEY` (`.env` is git-ignored and excluded from the Docker image).
|
| 490 |
- Hugging Face: add `SAWTAK_API_KEY` as a Space **secret** (Settings → Variables and secrets). It is injected as an environment variable at runtime.
|
| 491 |
+
- Playback, candidate generation and score calibration all use the Palestinian voice רַאאִף.
|
| 492 |
- Without a key the endpoint answers 503 and the page falls back to the device's Arabic voice.
|
| 493 |
+
- The same רַאאִף voice calibrates the scores (step 5 above), once for every phrase that gets scored, also when the learner never presses "השמע". The first score of a phrase waits up to 8 s for it. Without a key the scores are not calibrated; `/api/health` reports this as `references`.
|
app/arabic_diacritics.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add Arabic vowel marks to an answer from the pronunciation stored beside it.
|
| 2 |
+
|
| 3 |
+
The phrase bank deliberately keeps a plain Arabic spelling and an exact IPA reading.
|
| 4 |
+
This module combines them for the learner-facing answer without changing the spelling
|
| 5 |
+
used by the scorer or by text-to-speech.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
|
| 12 |
+
from .archi import _LETTERS, _align, _letters, _units
|
| 13 |
+
from .phones import base
|
| 14 |
+
|
| 15 |
+
FATHA, DAMMA, KASRA, SUKUN, SHADDA = "\u064e", "\u064f", "\u0650", "\u0652", "\u0651"
|
| 16 |
+
_MARKS = re.compile(r"[\u0610-\u061a\u064b-\u065f\u0670\u06d6-\u06ed]")
|
| 17 |
+
_VOWELS = {"a", "i", "u", "e", "o", "ə"}
|
| 18 |
+
_VOWEL_MARK = {"a": FATHA, "i": KASRA, "e": KASRA, "ə": KASRA, "u": DAMMA, "o": DAMMA}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _vowel(ph: str) -> str | None:
|
| 22 |
+
sound = base(ph)
|
| 23 |
+
return sound if sound in _VOWELS else None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _point_word(word: str, ipa_word: str) -> str:
|
| 27 |
+
plain = _MARKS.sub("", word)
|
| 28 |
+
chars = list(plain)
|
| 29 |
+
letters = _letters(plain)
|
| 30 |
+
phs = ipa_word.split()
|
| 31 |
+
units = _units(phs)
|
| 32 |
+
if not letters or not units:
|
| 33 |
+
return plain
|
| 34 |
+
|
| 35 |
+
matched, _cost = _align(letters, [sound for sound, _positions in units])
|
| 36 |
+
unit_for_letter: dict[int, int] = {}
|
| 37 |
+
for letter_i, hits in matched.items():
|
| 38 |
+
real = [hit for hit in hits if hit >= 0]
|
| 39 |
+
if len(hits) == 1 and len(real) == 1:
|
| 40 |
+
unit_for_letter[letter_i] = real[0]
|
| 41 |
+
|
| 42 |
+
if not unit_for_letter:
|
| 43 |
+
return plain
|
| 44 |
+
|
| 45 |
+
marks: dict[int, list[str]] = {}
|
| 46 |
+
ordered = sorted(unit_for_letter.items(), key=lambda item: item[1])
|
| 47 |
+
for order_i, (letter_i, unit_i) in enumerate(ordered):
|
| 48 |
+
positions = units[unit_i][1]
|
| 49 |
+
if len(positions) > 1:
|
| 50 |
+
marks.setdefault(letter_i, []).append(SHADDA)
|
| 51 |
+
|
| 52 |
+
end = positions[-1] + 1
|
| 53 |
+
next_start = units[unit_i + 1][1][0] if unit_i + 1 < len(units) else len(phs)
|
| 54 |
+
following = next((_vowel(phs[i]) for i in range(end, next_start) if _vowel(phs[i])), None)
|
| 55 |
+
if following:
|
| 56 |
+
target = letter_i
|
| 57 |
+
next_letter_i = ordered[order_i + 1][0] if order_i + 1 < len(ordered) else len(letters)
|
| 58 |
+
between = range(letter_i + 1, next_letter_i)
|
| 59 |
+
# A final ta marbuta / h carries its spoken ending (qahwe, madrase).
|
| 60 |
+
ending = next((i for i in between if letters[i] in "ةه" and i == len(letters) - 1), None)
|
| 61 |
+
if ending is not None:
|
| 62 |
+
target = ending
|
| 63 |
+
marks.setdefault(target, []).append(_VOWEL_MARK[following])
|
| 64 |
+
else:
|
| 65 |
+
marks.setdefault(letter_i, []).append(SUKUN)
|
| 66 |
+
|
| 67 |
+
# A word-initial vowel belongs on its alif/hamza, before the first aligned consonant.
|
| 68 |
+
first_unit = ordered[0][1] if ordered else 0
|
| 69 |
+
first_phone = units[first_unit][1][0]
|
| 70 |
+
initial = next((_vowel(ph) for ph in phs[:first_phone] if _vowel(ph)), None)
|
| 71 |
+
if initial:
|
| 72 |
+
first_letter = ordered[0][0]
|
| 73 |
+
carrier = next((i for i in range(first_letter + 1) if letters[i] in "اٱأإآء"), first_letter)
|
| 74 |
+
marks.setdefault(carrier, []).append(_VOWEL_MARK[initial])
|
| 75 |
+
|
| 76 |
+
# In a sun-letter article the lam is silent but is still explicitly vowelless.
|
| 77 |
+
if plain.startswith("ال") and 1 not in unit_for_letter:
|
| 78 |
+
marks.setdefault(1, []).append(SUKUN)
|
| 79 |
+
|
| 80 |
+
output = []
|
| 81 |
+
letter_i = 0
|
| 82 |
+
for char in chars:
|
| 83 |
+
output.append(char)
|
| 84 |
+
if char in _LETTERS:
|
| 85 |
+
own = marks.get(letter_i, [])
|
| 86 |
+
output.extend(mark for mark in (SHADDA if SHADDA in own else "", next((m for m in own if m != SHADDA), "")) if mark)
|
| 87 |
+
letter_i += 1
|
| 88 |
+
return "".join(output)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@lru_cache(maxsize=16384)
|
| 92 |
+
def point_arabic(arabic: str, ipa: str) -> str:
|
| 93 |
+
"""Return the original Arabic spelling with marks for its IPA pronunciation."""
|
| 94 |
+
ipa_words = [part.strip() for part in ipa.split("|")]
|
| 95 |
+
word_i = 0
|
| 96 |
+
|
| 97 |
+
def point(match: re.Match[str]) -> str:
|
| 98 |
+
nonlocal word_i
|
| 99 |
+
word = match.group(0)
|
| 100 |
+
if word_i >= len(ipa_words):
|
| 101 |
+
return word
|
| 102 |
+
result = _point_word(word, ipa_words[word_i])
|
| 103 |
+
word_i += 1
|
| 104 |
+
return result
|
| 105 |
+
|
| 106 |
+
return re.sub(r"[^\s]+", point, arabic) if len(arabic.split()) == len(ipa_words) else arabic
|
app/main.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
Run: uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 4 |
Env: MODEL_ID (default facebook/wav2vec2-xlsr-53-espeak-cv-ft, or a path to a fine-tuned checkpoint)
|
| 5 |
-
SAWTAK_API_KEY
|
| 6 |
SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY (Google sign-in, see app/auth.py)
|
| 7 |
TEACHER_EMAILS, SUPABASE_SECRET_KEY (the teacher's management panel, its classes and the lesson videos, see app/auth.py)
|
| 8 |
TUTORS_EMAIL (more accounts that, like the teachers, flag a recording scored wrong, see app/auth.py)
|
|
@@ -28,6 +28,7 @@ from fastapi import BackgroundTasks, Body, Depends, FastAPI, File, Form, HTTPExc
|
|
| 28 |
from fastapi.responses import HTMLResponse, Response
|
| 29 |
from fastapi.staticfiles import StaticFiles
|
| 30 |
|
|
|
|
| 31 |
from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
|
| 32 |
from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
|
| 33 |
from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
|
|
@@ -36,7 +37,7 @@ from .rules import TOGGLES, check_detectable
|
|
| 36 |
from .scorer import Scorer, decode_audio, load_scorer
|
| 37 |
from .speech_evaluation import SpeechEvaluationService
|
| 38 |
from .texts import read_line, text_phrases
|
| 39 |
-
from .tts import VOICES, SawtakTTS, TTSUnavailable, load_tts
|
| 40 |
from .verb_game import VerbExerciseGenerator
|
| 41 |
|
| 42 |
logging.basicConfig(level=logging.INFO)
|
|
@@ -91,7 +92,8 @@ def placed(rows: list[dict], lesson: str) -> list[dict]:
|
|
| 91 |
# bank's: TEXT_BASE + the text's id * TEXT_SPAN + the line's number, so it holds as texts come and go.
|
| 92 |
TEXT_BASE, TEXT_SPAN = 1_000_000, 1000
|
| 93 |
MAX_LINES, MAX_LINE = 200, 300
|
| 94 |
-
|
|
|
|
| 95 |
CREATE_TEXTS = ("create table public.texts (id bigint generated always as identity primary key, lesson text not null, "
|
| 96 |
"title text not null, voices text[] not null default '{}', lines jsonb not null, pos int, "
|
| 97 |
"ord int not null default 0, created_at timestamptz not null default now()); "
|
|
@@ -952,7 +954,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 952 |
return {"categories": [{"id": f"t{r['id']}", "name": r["title"], "lesson": r["lesson"], "story": True, "text": r["id"],
|
| 953 |
**({"voices": r["voices"]} if r["voices"] else {}), "pos": r["pos"], "ord": r["ord"],
|
| 954 |
**({"guessed": built(r)[1]} if user["teacher"] else {})} for r in rows],
|
| 955 |
-
"phrases": [{"id": i, "cat": p["cat"], "he": p["he"], "tr": p["tr"], "ar": p["variants"][0][0],
|
|
|
|
| 956 |
for i, p in text_bank(rows).items()]}
|
| 957 |
|
| 958 |
def text_store_error(e: AuthUnavailable) -> str:
|
|
@@ -1226,27 +1229,35 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 1226 |
"dialect_realisations_missing": {k: sorted(v) for k, v in s.vocab.class_missing.items()}}
|
| 1227 |
|
| 1228 |
@app.get("/api/tts/{phrase_id}", dependencies=members)
|
| 1229 |
-
def tts_phrase(phrase_id: int
|
| 1230 |
-
"""The model answer
|
| 1231 |
-
|
| 1232 |
-
`voice` picks one of the voices in tts.VOICES instead of the configured one.
|
| 1233 |
-
"""
|
| 1234 |
names = {i: n for n, i in VOICES.items()}
|
| 1235 |
-
|
| 1236 |
-
raise HTTPException(400, "unknown voice")
|
| 1237 |
-
text = phrase_of(phrase_id)["variants"][0][0]
|
| 1238 |
if app.state.tts is None:
|
| 1239 |
raise HTTPException(503, "tts not configured")
|
| 1240 |
try:
|
| 1241 |
-
data, used = app.state.tts.wav(text,
|
| 1242 |
except TTSUnavailable as e:
|
| 1243 |
raise HTTPException(503, str(e))
|
| 1244 |
tts = app.state.tts # debug: lets the page show which voice spoke
|
| 1245 |
return Response(data, media_type="audio/wav", headers={
|
| 1246 |
-
"Cache-Control": "private,
|
| 1247 |
"X-TTS-Voice-Id": urllib.parse.quote(used),
|
| 1248 |
"X-TTS-Voice-Name": urllib.parse.quote(names.get(used) or getattr(tts, "voice_name", ""))})
|
| 1249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1250 |
@app.get("/api/prompt-audio/{phrase_id}", dependencies=members)
|
| 1251 |
def prompt_audio(phrase_id: int, engine: str = "gtts"):
|
| 1252 |
"""The Hebrew prompt read aloud (edge-tts or gTTS). 503 tells the page to stay silent."""
|
|
|
|
| 2 |
|
| 3 |
Run: uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 4 |
Env: MODEL_ID (default facebook/wav2vec2-xlsr-53-espeak-cv-ft, or a path to a fine-tuned checkpoint)
|
| 5 |
+
SAWTAK_API_KEY (model-answer playback with the Raef voice, see app/tts.py)
|
| 6 |
SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY (Google sign-in, see app/auth.py)
|
| 7 |
TEACHER_EMAILS, SUPABASE_SECRET_KEY (the teacher's management panel, its classes and the lesson videos, see app/auth.py)
|
| 8 |
TUTORS_EMAIL (more accounts that, like the teachers, flag a recording scored wrong, see app/auth.py)
|
|
|
|
| 28 |
from fastapi.responses import HTMLResponse, Response
|
| 29 |
from fastapi.staticfiles import StaticFiles
|
| 30 |
|
| 31 |
+
from .arabic_diacritics import point_arabic
|
| 32 |
from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
|
| 33 |
from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
|
| 34 |
from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
|
|
|
|
| 37 |
from .scorer import Scorer, decode_audio, load_scorer
|
| 38 |
from .speech_evaluation import SpeechEvaluationService
|
| 39 |
from .texts import read_line, text_phrases
|
| 40 |
+
from .tts import RAEF_VOICE_ID, VOICES, SawtakTTS, TTSUnavailable, load_tts
|
| 41 |
from .verb_game import VerbExerciseGenerator
|
| 42 |
|
| 43 |
logging.basicConfig(level=logging.INFO)
|
|
|
|
| 92 |
# bank's: TEXT_BASE + the text's id * TEXT_SPAN + the line's number, so it holds as texts come and go.
|
| 93 |
TEXT_BASE, TEXT_SPAN = 1_000_000, 1000
|
| 94 |
MAX_LINES, MAX_LINE = 200, 300
|
| 95 |
+
# Historical dialogues retain their speaker names even though every line is now played by Raef.
|
| 96 |
+
VOICE_NAMES = ["אמיר", "ראאף", "חנאן"]
|
| 97 |
CREATE_TEXTS = ("create table public.texts (id bigint generated always as identity primary key, lesson text not null, "
|
| 98 |
"title text not null, voices text[] not null default '{}', lines jsonb not null, pos int, "
|
| 99 |
"ord int not null default 0, created_at timestamptz not null default now()); "
|
|
|
|
| 954 |
return {"categories": [{"id": f"t{r['id']}", "name": r["title"], "lesson": r["lesson"], "story": True, "text": r["id"],
|
| 955 |
**({"voices": r["voices"]} if r["voices"] else {}), "pos": r["pos"], "ord": r["ord"],
|
| 956 |
**({"guessed": built(r)[1]} if user["teacher"] else {})} for r in rows],
|
| 957 |
+
"phrases": [{"id": i, "cat": p["cat"], "he": p["he"], "tr": p["tr"], "ar": p["variants"][0][0],
|
| 958 |
+
"ar_pointed": point_arabic(*p["variants"][0]), "seq": p["seq"]}
|
| 959 |
for i, p in text_bank(rows).items()]}
|
| 960 |
|
| 961 |
def text_store_error(e: AuthUnavailable) -> str:
|
|
|
|
| 1229 |
"dialect_realisations_missing": {k: sorted(v) for k, v in s.vocab.class_missing.items()}}
|
| 1230 |
|
| 1231 |
@app.get("/api/tts/{phrase_id}", dependencies=members)
|
| 1232 |
+
def tts_phrase(phrase_id: int):
|
| 1233 |
+
"""The model answer spoken by Raef. 503 tells the page to use the device voice."""
|
|
|
|
|
|
|
|
|
|
| 1234 |
names = {i: n for n, i in VOICES.items()}
|
| 1235 |
+
text = point_arabic(*phrase_of(phrase_id)["variants"][0])
|
|
|
|
|
|
|
| 1236 |
if app.state.tts is None:
|
| 1237 |
raise HTTPException(503, "tts not configured")
|
| 1238 |
try:
|
| 1239 |
+
data, used = app.state.tts.wav(text, RAEF_VOICE_ID)
|
| 1240 |
except TTSUnavailable as e:
|
| 1241 |
raise HTTPException(503, str(e))
|
| 1242 |
tts = app.state.tts # debug: lets the page show which voice spoke
|
| 1243 |
return Response(data, media_type="audio/wav", headers={
|
| 1244 |
+
"Cache-Control": "private, no-store", "X-TTS-Provider": "sawtakarabi.ai",
|
| 1245 |
"X-TTS-Voice-Id": urllib.parse.quote(used),
|
| 1246 |
"X-TTS-Voice-Name": urllib.parse.quote(names.get(used) or getattr(tts, "voice_name", ""))})
|
| 1247 |
|
| 1248 |
+
@app.delete("/api/tts-cache")
|
| 1249 |
+
def clear_tts_cache(_a: SupabaseAuth = Depends(teacher_only)):
|
| 1250 |
+
"""Delete generated Sawtak recordings; the next playback generates them again."""
|
| 1251 |
+
if app.state.tts is None:
|
| 1252 |
+
raise HTTPException(503, "tts not configured")
|
| 1253 |
+
try:
|
| 1254 |
+
removed = app.state.tts.clear_cache()
|
| 1255 |
+
except TTSUnavailable as e:
|
| 1256 |
+
raise HTTPException(503, str(e))
|
| 1257 |
+
if app.state.refs:
|
| 1258 |
+
app.state.refs.invalidate()
|
| 1259 |
+
return {"ok": True, "removed": removed}
|
| 1260 |
+
|
| 1261 |
@app.get("/api/prompt-audio/{phrase_id}", dependencies=members)
|
| 1262 |
def prompt_audio(phrase_id: int, engine: str = "gtts"):
|
| 1263 |
"""The Hebrew prompt read aloud (edge-tts or gTTS). 503 tells the page to stay silent."""
|
app/phrases.py
CHANGED
|
@@ -11,6 +11,7 @@ Conventions (Palestinian urban speech as the main form):
|
|
| 11 |
Emphatics are written with ˤ (sˤ tˤ dˤ ðˤ), long vowels with ː.
|
| 12 |
Geminates are written doubled; the tokenizer collapses them for CTC.
|
| 13 |
"""
|
|
|
|
| 14 |
from .grammar import day2_phrases
|
| 15 |
from .workbook import CATEGORIES as WORKBOOK_CATEGORIES, GAMES as WORKBOOK_GAMES, LESSON_TABS, LEVEL_TABS, workbook_phrases
|
| 16 |
|
|
@@ -147,6 +148,7 @@ def public_phrases():
|
|
| 147 |
shown = {c["id"] for c in CATEGORIES + GAMES}
|
| 148 |
return [
|
| 149 |
{"id": i, "cat": p["cat"], "he": p["he"], "tr": p["tr"], "ar": p["variants"][0][0],
|
|
|
|
| 150 |
**{k: p[k] for k in ("grp", "pron", "neg", "listed", "seq", "read", "unlock", "binyan", "gizra") if k in p}}
|
| 151 |
for i, p in enumerate(PHRASES) if p["cat"] in shown
|
| 152 |
]
|
|
|
|
| 11 |
Emphatics are written with ˤ (sˤ tˤ dˤ ðˤ), long vowels with ː.
|
| 12 |
Geminates are written doubled; the tokenizer collapses them for CTC.
|
| 13 |
"""
|
| 14 |
+
from .arabic_diacritics import point_arabic
|
| 15 |
from .grammar import day2_phrases
|
| 16 |
from .workbook import CATEGORIES as WORKBOOK_CATEGORIES, GAMES as WORKBOOK_GAMES, LESSON_TABS, LEVEL_TABS, workbook_phrases
|
| 17 |
|
|
|
|
| 148 |
shown = {c["id"] for c in CATEGORIES + GAMES}
|
| 149 |
return [
|
| 150 |
{"id": i, "cat": p["cat"], "he": p["he"], "tr": p["tr"], "ar": p["variants"][0][0],
|
| 151 |
+
"ar_pointed": point_arabic(*p["variants"][0]),
|
| 152 |
**{k: p[k] for k in ("grp", "pron", "neg", "listed", "seq", "read", "unlock", "binyan", "gizra") if k in p}}
|
| 153 |
for i, p in enumerate(PHRASES) if p["cat"] in shown
|
| 154 |
]
|
app/reference.py
CHANGED
|
@@ -16,6 +16,7 @@ import threading
|
|
| 16 |
import time
|
| 17 |
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
| 18 |
|
|
|
|
| 19 |
from .scorer import Reference, Scorer, decode_audio
|
| 20 |
|
| 21 |
log = logging.getLogger("ihki.reference")
|
|
@@ -39,7 +40,7 @@ class References:
|
|
| 39 |
|
| 40 |
def get(self, phrase_id: int, phrase: dict) -> list[Reference]:
|
| 41 |
"""The references of a phrase that are ready within the wait."""
|
| 42 |
-
text = phrase["variants"][0]
|
| 43 |
jobs: list[tuple[tuple[int, str], Future]] = []
|
| 44 |
with self._lock:
|
| 45 |
for n, voice in enumerate(self.voices):
|
|
@@ -67,3 +68,18 @@ class References:
|
|
| 67 |
if ref is not None:
|
| 68 |
refs.append(ref)
|
| 69 |
return refs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
import time
|
| 17 |
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
| 18 |
|
| 19 |
+
from .arabic_diacritics import point_arabic
|
| 20 |
from .scorer import Reference, Scorer, decode_audio
|
| 21 |
|
| 22 |
log = logging.getLogger("ihki.reference")
|
|
|
|
| 40 |
|
| 41 |
def get(self, phrase_id: int, phrase: dict) -> list[Reference]:
|
| 42 |
"""The references of a phrase that are ready within the wait."""
|
| 43 |
+
text = point_arabic(*phrase["variants"][0])
|
| 44 |
jobs: list[tuple[tuple[int, str], Future]] = []
|
| 45 |
with self._lock:
|
| 46 |
for n, voice in enumerate(self.voices):
|
|
|
|
| 68 |
if ref is not None:
|
| 69 |
refs.append(ref)
|
| 70 |
return refs
|
| 71 |
+
|
| 72 |
+
def invalidate(self, phrase_id: int | None = None, voice: str | None = None) -> int:
|
| 73 |
+
"""Forget matching in-memory baselines so the next score rebuilds them from the current WAV."""
|
| 74 |
+
with self._lock:
|
| 75 |
+
keys = [key for key in self._jobs
|
| 76 |
+
if (phrase_id is None or key[0] == phrase_id) and (voice is None or key[1] == voice)]
|
| 77 |
+
for key in keys:
|
| 78 |
+
job = self._jobs.pop(key)
|
| 79 |
+
job.cancel() # harmless when generation or analysis has already started
|
| 80 |
+
self._retry_at.pop(key, None)
|
| 81 |
+
retry_keys = [key for key in self._retry_at
|
| 82 |
+
if (phrase_id is None or key[0] == phrase_id) and (voice is None or key[1] == voice)]
|
| 83 |
+
for key in retry_keys:
|
| 84 |
+
self._retry_at.pop(key, None)
|
| 85 |
+
return len(keys)
|
app/tts.py
CHANGED
|
@@ -4,7 +4,6 @@ The API key stays on the server: the browser asks for a phrase by id and gets a
|
|
| 4 |
Only phrases from the phrase bank can be synthesised, and each one is generated once and cached on disk.
|
| 5 |
|
| 6 |
Env: SAWTAK_API_KEY required; without it /api/tts answers 503 and the page falls back to the device voice
|
| 7 |
-
SAWTAK_VOICE_ID optional; default is the most liked ready Palestinian voice in the catalog
|
| 8 |
TTS_CACHE_DIR optional; default ~/.cache/ihki-tts
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
|
@@ -16,7 +15,6 @@ import logging
|
|
| 16 |
import os
|
| 17 |
import threading
|
| 18 |
import urllib.error
|
| 19 |
-
import urllib.parse
|
| 20 |
import urllib.request
|
| 21 |
import wave
|
| 22 |
from pathlib import Path
|
|
@@ -24,23 +22,20 @@ from pathlib import Path
|
|
| 24 |
log = logging.getLogger("ihki.tts")
|
| 25 |
BASE_URL = "https://api.sawtakarabi.ai/v1"
|
| 26 |
MODEL = "arabic-tts-1"
|
| 27 |
-
|
| 28 |
TIMEOUT = 60
|
| 29 |
-
#
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
"רַאאִף": "273d81b2-991b-5b76-8525-eeacc6a3d759",
|
| 33 |
-
"חנאן": "e41781f5-127c-5eb0-b83c-50bf52a64caf",
|
| 34 |
-
}
|
| 35 |
|
| 36 |
class TTSUnavailable(Exception):
|
| 37 |
"""No key configured, or the provider refused / failed. The message is safe to show."""
|
| 38 |
|
| 39 |
|
| 40 |
class SawtakTTS:
|
| 41 |
-
def __init__(self, api_key: str, voice_id: str =
|
| 42 |
-
self.api_key, self.voice_id = api_key,
|
| 43 |
-
self.voice_name = ""
|
| 44 |
self.cache_dir = cache_dir or Path.home() / ".cache" / "ihki-tts"
|
| 45 |
self._lock = threading.Lock() # one generation at a time; also guards the voice lookup
|
| 46 |
|
|
@@ -65,50 +60,59 @@ class SawtakTTS:
|
|
| 65 |
log.warning("sawtak %s unreachable: %s", path, e)
|
| 66 |
raise TTSUnavailable("tts provider unreachable") from None
|
| 67 |
|
| 68 |
-
def _voice(self) -> str:
|
| 69 |
-
if not self.voice_id:
|
| 70 |
-
query = urllib.parse.urlencode(
|
| 71 |
-
[("dialect", d) for d in PALESTINIAN] + [("sort", "most_liked"), ("limit", "25")])
|
| 72 |
-
with self._request(f"/voices?{query}") as res:
|
| 73 |
-
voices = [v for v in json.load(res).get("data", []) if v.get("status") == "ready"]
|
| 74 |
-
if not voices:
|
| 75 |
-
raise TTSUnavailable("no Palestinian voice in the catalog; set SAWTAK_VOICE_ID")
|
| 76 |
-
self.voice_id, self.voice_name = voices[0]["id"], voices[0].get("name") or ""
|
| 77 |
-
log.info("sawtak voice: %s (%s, %s)", voices[0].get("name"), self.voice_id,
|
| 78 |
-
(voices[0].get("labels") or {}).get("dialect"))
|
| 79 |
-
return self.voice_id
|
| 80 |
-
|
| 81 |
def _path(self, text: str, voice: str) -> Path:
|
| 82 |
-
return self.cache_dir / (hashlib.sha1(f"{MODEL}|{voice}|{text}".encode()).hexdigest() + ".wav")
|
| 83 |
|
| 84 |
def cached(self, text: str, voice: str) -> bool:
|
| 85 |
"""Has this voice said `text` before? Then wav() answers from disk, without the provider."""
|
| 86 |
return self._path(text, voice).exists()
|
| 87 |
|
| 88 |
-
def
|
| 89 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
|
| 92 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
with self._lock:
|
| 94 |
-
voice =
|
| 95 |
path = self._path(text, voice)
|
| 96 |
if path.exists():
|
| 97 |
return path.read_bytes(), voice
|
| 98 |
-
#
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
rate = int(res.headers.get("X-Sample-Rate", 24000))
|
| 102 |
-
pcm = res.read()
|
| 103 |
-
if len(pcm) < rate // 5: # an error after streaming starts just cuts the audio short
|
| 104 |
-
raise TTSUnavailable("tts provider returned no audio")
|
| 105 |
-
buf = io.BytesIO()
|
| 106 |
-
with wave.open(buf, "wb") as w: # raw stream is signed 16-bit little-endian mono
|
| 107 |
-
w.setnchannels(1)
|
| 108 |
-
w.setsampwidth(2)
|
| 109 |
-
w.setframerate(rate)
|
| 110 |
-
w.writeframes(pcm[: len(pcm) // 2 * 2])
|
| 111 |
-
data = buf.getvalue()
|
| 112 |
try:
|
| 113 |
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 114 |
tmp = path.with_suffix(".tmp")
|
|
@@ -125,4 +129,4 @@ def load_tts() -> SawtakTTS | None:
|
|
| 125 |
log.info("SAWTAK_API_KEY not set: playback uses the device voice")
|
| 126 |
return None
|
| 127 |
cache = os.environ.get("TTS_CACHE_DIR", "").strip()
|
| 128 |
-
return SawtakTTS(key,
|
|
|
|
| 4 |
Only phrases from the phrase bank can be synthesised, and each one is generated once and cached on disk.
|
| 5 |
|
| 6 |
Env: SAWTAK_API_KEY required; without it /api/tts answers 503 and the page falls back to the device voice
|
|
|
|
| 7 |
TTS_CACHE_DIR optional; default ~/.cache/ihki-tts
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
|
|
|
| 15 |
import os
|
| 16 |
import threading
|
| 17 |
import urllib.error
|
|
|
|
| 18 |
import urllib.request
|
| 19 |
import wave
|
| 20 |
from pathlib import Path
|
|
|
|
| 22 |
log = logging.getLogger("ihki.tts")
|
| 23 |
BASE_URL = "https://api.sawtakarabi.ai/v1"
|
| 24 |
MODEL = "arabic-tts-1"
|
| 25 |
+
CACHE_VERSION = "raef-pointed-enhanced-provider-default-v1"
|
| 26 |
TIMEOUT = 60
|
| 27 |
+
# The single Sawtak voice used throughout the application.
|
| 28 |
+
RAEF_VOICE_ID = "273d81b2-991b-5b76-8525-eeacc6a3d759"
|
| 29 |
+
VOICES = {"רַאאִף": RAEF_VOICE_ID}
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
class TTSUnavailable(Exception):
|
| 32 |
"""No key configured, or the provider refused / failed. The message is safe to show."""
|
| 33 |
|
| 34 |
|
| 35 |
class SawtakTTS:
|
| 36 |
+
def __init__(self, api_key: str, voice_id: str = RAEF_VOICE_ID, cache_dir: Path | None = None):
|
| 37 |
+
self.api_key, self.voice_id = api_key, RAEF_VOICE_ID
|
| 38 |
+
self.voice_name = "רַאאִף"
|
| 39 |
self.cache_dir = cache_dir or Path.home() / ".cache" / "ihki-tts"
|
| 40 |
self._lock = threading.Lock() # one generation at a time; also guards the voice lookup
|
| 41 |
|
|
|
|
| 60 |
log.warning("sawtak %s unreachable: %s", path, e)
|
| 61 |
raise TTSUnavailable("tts provider unreachable") from None
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
def _path(self, text: str, voice: str) -> Path:
|
| 64 |
+
return self.cache_dir / (hashlib.sha1(f"{CACHE_VERSION}|{MODEL}|{voice}|{text}".encode()).hexdigest() + ".wav")
|
| 65 |
|
| 66 |
def cached(self, text: str, voice: str) -> bool:
|
| 67 |
"""Has this voice said `text` before? Then wav() answers from disk, without the provider."""
|
| 68 |
return self._path(text, voice).exists()
|
| 69 |
|
| 70 |
+
def _generate(self, text: str, voice: str) -> bytes:
|
| 71 |
+
"""Generate one uncached WAV recording."""
|
| 72 |
+
# Sawtak currently returns 320 seconds of silence whenever `temperature` is present,
|
| 73 |
+
# including for 0 and 0.1. Omitting it uses the provider's deterministic supported path.
|
| 74 |
+
with self._request("/audio/speech", {"model": MODEL, "voice": voice, "input": text,
|
| 75 |
+
"response_format": "pcm", "enhance_pronunciation": True,
|
| 76 |
+
}) as res:
|
| 77 |
+
rate = int(res.headers.get("X-Sample-Rate", 24000))
|
| 78 |
+
pcm = res.read()
|
| 79 |
+
if len(pcm) < rate // 5:
|
| 80 |
+
raise TTSUnavailable("tts provider returned no audio")
|
| 81 |
+
buf = io.BytesIO()
|
| 82 |
+
with wave.open(buf, "wb") as w:
|
| 83 |
+
w.setnchannels(1)
|
| 84 |
+
w.setsampwidth(2)
|
| 85 |
+
w.setframerate(rate)
|
| 86 |
+
w.writeframes(pcm[: len(pcm) // 2 * 2])
|
| 87 |
+
return buf.getvalue()
|
| 88 |
|
| 89 |
+
def clear_cache(self) -> int:
|
| 90 |
+
"""Delete only generated audio files from this instance's dedicated cache directory."""
|
| 91 |
+
with self._lock:
|
| 92 |
+
if not self.cache_dir.is_dir():
|
| 93 |
+
return 0
|
| 94 |
+
removed = 0
|
| 95 |
+
for path in self.cache_dir.iterdir():
|
| 96 |
+
if path.is_file() and (path.suffix == ".wav" or path.name.endswith(".tmp")):
|
| 97 |
+
try:
|
| 98 |
+
path.unlink()
|
| 99 |
+
removed += 1
|
| 100 |
+
except FileNotFoundError:
|
| 101 |
+
pass
|
| 102 |
+
except OSError as e:
|
| 103 |
+
raise TTSUnavailable(f"could not clear voice cache: {e}") from None
|
| 104 |
+
return removed
|
| 105 |
+
|
| 106 |
+
def wav(self, text: str, voice: str = "") -> tuple[bytes, str]:
|
| 107 |
+
"""(WAV, Raef's voice id) for `text`, using the disk cache when available."""
|
| 108 |
with self._lock:
|
| 109 |
+
voice = RAEF_VOICE_ID
|
| 110 |
path = self._path(text, voice)
|
| 111 |
if path.exists():
|
| 112 |
return path.read_bytes(), voice
|
| 113 |
+
# The caller supplies the phrase's Palestinian vowel marks. Sawtak preserves existing
|
| 114 |
+
# marks; enhancement fills remaining pronunciation cues using the selected voice's dialect.
|
| 115 |
+
data = self._generate(text, voice)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
try:
|
| 117 |
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 118 |
tmp = path.with_suffix(".tmp")
|
|
|
|
| 129 |
log.info("SAWTAK_API_KEY not set: playback uses the device voice")
|
| 130 |
return None
|
| 131 |
cache = os.environ.get("TTS_CACHE_DIR", "").strip()
|
| 132 |
+
return SawtakTTS(key, RAEF_VOICE_ID, Path(cache) if cache else None)
|
docs/grading-pipeline.md
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# How grading works: from a recording to an exercise grade
|
| 2 |
+
|
| 3 |
+
This document traces one recording through the scorer, from the audio bytes and the reference text to the numbers the student and the teacher see. It is written for a reader who knows CTC, forced alignment and GOP, and wants the exact formulas, thresholds and order of operations this codebase uses.
|
| 4 |
+
|
| 5 |
+
The pipeline has no ASR in it. The model never transcribes freely and then diffs against the text. Instead, the reference text is turned into phoneme sequences, the audio is force-aligned to each sequence, and each phoneme is scored by how strongly the model's posteriors support it inside its own time window.
|
| 6 |
+
|
| 7 |
+
| Stage | Where |
|
| 8 |
+
|---|---|
|
| 9 |
+
| Audio decode, silence trim | [app/scorer.py](../app/scorer.py) `decode_audio` |
|
| 10 |
+
| Phoneme posteriors (wav2vec2 CTC) | [app/scorer.py](../app/scorer.py) `HFEmitter` |
|
| 11 |
+
| Vocab pooling, many-ways letters | [app/scorer.py](../app/scorer.py) `Vocab`, [app/phones.py](../app/phones.py), [app/archi.py](../app/archi.py) |
|
| 12 |
+
| CTC Viterbi, GOP, greedy decode, edit distance | [app/align.py](../app/align.py) |
|
| 13 |
+
| Linguistic rules, exemptions, margins | [app/rules.py](../app/rules.py), [static/assets/pronunciation-rules.md](../static/assets/pronunciation-rules.md) |
|
| 14 |
+
| Native baseline from TTS | [app/reference.py](../app/reference.py) |
|
| 15 |
+
| Result assembly, word and sentence score | [app/scorer.py](../app/scorer.py) `Scorer.score_logp` |
|
| 16 |
+
| HTTP endpoint | [app/main.py](../app/main.py) `POST /api/score` |
|
| 17 |
+
| Exercise-level grades | [static/index.html](../static/index.html), [app/speech_evaluation.py](../app/speech_evaluation.py) |
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## 1. The whole pipeline at a glance
|
| 22 |
+
|
| 23 |
+
```mermaid
|
| 24 |
+
flowchart TD
|
| 25 |
+
A["Browser recording<br/>webm/opus, ogg, mp4, wav"] --> B["decode_audio<br/>ffmpeg → 16 kHz mono f32<br/>cap 32 s, energy trim"]
|
| 26 |
+
R["Phrase from the bank<br/>Hebrew prompt + accepted variants<br/>(Arabic, IPA)"] --> V
|
| 27 |
+
B --> E["wav2vec2-xlsr-53-espeak-cv-ft<br/>log-softmax → logp (T × V)"]
|
| 28 |
+
E --> M["Vocab.merge<br/>pool spelling variants of one sound"]
|
| 29 |
+
V["Expand variants<br/>archify: Q, K, TH, DH, ZH, J, E<br/>+ diphthong forms"] --> T["Tokenise each variant<br/>collapse geminates / repeats"]
|
| 30 |
+
M --> X["Vocab.extend<br/>add pooled class columns"]
|
| 31 |
+
T --> X
|
| 32 |
+
X --> F["CTC Viterbi per variant<br/>keep max total log-prob"]
|
| 33 |
+
F --> G["GOP per token<br/>in its window"]
|
| 34 |
+
G --> H["Exemptions<br/>glottal drop · article vowel · near vowel"]
|
| 35 |
+
H --> I["Vowel-insertion cap<br/>rule 3.10"]
|
| 36 |
+
I --> J["Native baseline<br/>divide by TTS voice's score"]
|
| 37 |
+
J --> K["Teacher calibration floors"]
|
| 38 |
+
K --> L["Flags, tips, issues<br/>margins + rules"]
|
| 39 |
+
L --> W["Word score<br/>0.5·min + 0.5·mean"]
|
| 40 |
+
W --> S["Sentence score<br/>0.5·phone mean + 0.5·word mean"]
|
| 41 |
+
F --> U["Greedy decode vs best variant<br/>edit distance ratio"]
|
| 42 |
+
U -->|"over 0.65"| C["unrelated: cap at 30, no tips"]
|
| 43 |
+
C --> S
|
| 44 |
+
S --> O["JSON result 0–100"]
|
| 45 |
+
O --> P["Exercise grades<br/>band, best score, topic, quiz, verb game"]
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## 2. Request sequence
|
| 49 |
+
|
| 50 |
+
```mermaid
|
| 51 |
+
sequenceDiagram
|
| 52 |
+
autonumber
|
| 53 |
+
participant UI as Browser (index.html)
|
| 54 |
+
participant API as FastAPI /api/score
|
| 55 |
+
participant FF as ffmpeg
|
| 56 |
+
participant Ref as References (TTS baseline)
|
| 57 |
+
participant TTS as Sawtak TTS
|
| 58 |
+
participant Sc as Scorer
|
| 59 |
+
participant M as wav2vec2 CTC
|
| 60 |
+
participant DB as Supabase attempts
|
| 61 |
+
|
| 62 |
+
UI->>API: multipart {phrase_id, audio}
|
| 63 |
+
API->>FF: decode to 16 kHz mono float32
|
| 64 |
+
FF-->>API: wav (silence-trimmed)
|
| 65 |
+
alt wav shorter than 0.3 s
|
| 66 |
+
API-->>UI: {error: too_short}
|
| 67 |
+
end
|
| 68 |
+
API->>Ref: get(phrase_id, phrase), wait ≤ 8 s
|
| 69 |
+
opt first time this phrase is scored
|
| 70 |
+
Ref->>TTS: synthesise variant 0 (first voice)
|
| 71 |
+
TTS-->>Ref: native audio
|
| 72 |
+
Ref->>Sc: reference(wav, phrase)
|
| 73 |
+
Sc->>M: emit
|
| 74 |
+
M-->>Sc: logp
|
| 75 |
+
Sc-->>Ref: per-sound native scores, or None if untrustworthy
|
| 76 |
+
end
|
| 77 |
+
Ref-->>API: list of References (possibly empty)
|
| 78 |
+
API->>Sc: score(wav, phrase, refs, diagnostics=teacher)
|
| 79 |
+
Sc->>M: emit(wav)
|
| 80 |
+
M-->>Sc: logp (T × V)
|
| 81 |
+
Sc->>Sc: merge → variants → Viterbi → GOP → rules → baseline → floors → aggregate
|
| 82 |
+
Sc-->>API: result JSON
|
| 83 |
+
API-->>UI: result
|
| 84 |
+
API--)DB: background: {cat, phrase, score, unrelated, flagged issues}
|
| 85 |
+
UI->>UI: band label, letter colours, keep best score per sentence
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
The reference text never arrives from the client: the client sends only `phrase_id`, and the server looks up the phrase (bank phrase, teacher text line, or a verb-game form held server-side). That matters for the verb game, where the expected answer must not be exposed to the browser.
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## 3. Inputs
|
| 93 |
+
|
| 94 |
+
### 3.1 Audio
|
| 95 |
+
|
| 96 |
+
`decode_audio` pipes the upload through ffmpeg (`-ac 1 -ar 16000 -f f32le -t 32`). It then trims leading and trailing silence with a 20 ms RMS envelope (320 samples). A sample counts as voiced if its envelope is above `max(0.01, 0.05 · peak)`. 200 ms of padding is kept on each side. The trim keeps CTC from being fed seconds of blank, which would otherwise widen the first and last tokens' GOP windows.
|
| 97 |
+
|
| 98 |
+
Anything shorter than 0.3 s after trimming is rejected before inference.
|
| 99 |
+
|
| 100 |
+
### 3.2 Reference text: a phrase and its accepted variants
|
| 101 |
+
|
| 102 |
+
A phrase in [app/phrases.py](../app/phrases.py) (and the generated workbook, grammar and teacher texts) carries a list of **accepted variants**, each `(arabic, ipa)`:
|
| 103 |
+
|
| 104 |
+
```python
|
| 105 |
+
dict(cat="meet", he="אני מירושלים", variants=[
|
| 106 |
+
("انا من القدس", "ʔ a n a | m i n | i l ʔ u d s"),
|
| 107 |
+
("انا من القدس", "ʔ a n a | m i n | i l q u d s"),
|
| 108 |
+
])
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
IPA phonemes are space-separated, words are separated by `|`, and the Arabic and IPA must have the same word count. A variant is a genuinely different correct sentence (different lexical choice, a dropped word, a different vowel pattern). Dialectal realisations of single letters are handled by the next step instead.
|
| 112 |
+
|
| 113 |
+
### 3.3 Variant expansion: many-ways letters ("archiphonemes")
|
| 114 |
+
|
| 115 |
+
Six Arabic letters have more than one correct Palestinian realisation. [app/archi.py](../app/archi.py) `archify()` aligns each word's Arabic letters to its IPA consonants (a small edit-distance DP with cheap skips for weak letters like ا و ي ة ل). It then rewrites the phoneme that came from such a letter as `SYMBOL:written`:
|
| 116 |
+
|
| 117 |
+
| Symbol | Letter | Accepted realisations (rules §5) |
|
| 118 |
+
|---|---|---|
|
| 119 |
+
| `Q` | ق | ʔ (urban), k (rural), q (also ok); g only if `optional_realisations_enabled` |
|
| 120 |
+
| `K` | ك | k, tʃ (rural) |
|
| 121 |
+
| `TH` | ث | t, θ; s only in learned words where the phrase writes s |
|
| 122 |
+
| `DH` | ذ | d, ð; z only in learned words where the phrase writes z |
|
| 123 |
+
| `ZH` | ظ | dˤ, zˤ, ðˤ |
|
| 124 |
+
| `J` | ج | dʒ, ʒ |
|
| 125 |
+
| `E` | final ة | a, e, i (positional) |
|
| 126 |
+
| `O` | pronoun ending | o, u (positional) |
|
| 127 |
+
|
| 128 |
+
So `ʔ a h w e` for قهوة becomes `Q:ʔ a h w E:e`. The written phoneme is kept for display, tips and calibration keys. The class membership is what gets scored.
|
| 129 |
+
|
| 130 |
+
Two more expansions:
|
| 131 |
+
|
| 132 |
+
- **Diphthongs** (§6.3 #8). `diphthong_variants()` adds a copy of the variant with `eː → a j` and `oː → a w` where the Arabic writes ي / و. A diphthong is two tokens instead of one, so it cannot be a class. It becomes a separate variant that competes in the Viterbi step.
|
| 133 |
+
- **Deduplication.** Variants that differ only in how a many-ways letter is said collapse into one after archifying.
|
| 134 |
+
|
| 135 |
+
A teacher can switch off any of these (the `dialect_*`, `final_vowel` and `diphthong` toggles). The scorer then requires the phoneme as written.
|
| 136 |
+
|
| 137 |
+
---
|
| 138 |
+
|
| 139 |
+
## 4. Acoustic model and vocabulary handling
|
| 140 |
+
|
| 141 |
+
### 4.1 Emissions
|
| 142 |
+
|
| 143 |
+
`HFEmitter` runs `facebook/wav2vec2-xlsr-53-espeak-cv-ft` (or a fine-tuned checkpoint via `MODEL_ID`) and returns `log_softmax(logits)`, shape `(T, V)`. That is ~50 frames/s (20 ms hop), V ≈ 392 espeak IPA symbols plus `<pad>` as the CTC blank.
|
| 144 |
+
|
| 145 |
+
Nothing downstream depends on torch. `Scorer` takes any `Emitter` callable, which is how [tests/test_scoring.py](../tests/test_scoring.py) drives the full scorer with synthetic posteriors.
|
| 146 |
+
|
| 147 |
+
### 4.2 Pooling spelling variants (`Vocab.merge`)
|
| 148 |
+
|
| 149 |
+
The multilingual vocab spells one sound several ways (`t / t̪ / tʰ / tː`, `r / ɾ`, `a / æ / ɐ`, `x / χ`). Left alone, a correctly said `t` would lose its GOP to its own allophone `t̪`. [app/phones.py](../app/phones.py) `base()` maps each symbol to a canonical sound by stripping length, aspiration, dentality, nasality and so on, and folding a fixed equivalence table. Emphatics stay distinct.
|
| 150 |
+
|
| 151 |
+
`merge()` replaces each group's columns with one representative column holding the group's `logsumexp`. The other columns are set to −1e9.
|
| 152 |
+
|
| 153 |
+
> A consequence: vowel length is invisible to the scorer, because `aː` and `a` share a base. Length errors can surface only through the margins and rules on the written phoneme, not through separate posteriors.
|
| 154 |
+
|
| 155 |
+
### 4.3 Class columns for many-ways letters (`Vocab.add_class`, `Vocab.extend`)
|
| 156 |
+
|
| 157 |
+
For a token like `Q:ʔ`, the scorer looks up each accepted realisation's model id (falling back via `FALLBACKS`, e.g. `ðˤ → zˤ → z`, when the vocab lacks a symbol). If more than one distinct id remains, it allocates a **virtual id** past the real vocab. `extend()` appends one column per virtual id: the `logsumexp` of its members' posteriors.
|
| 158 |
+
|
| 159 |
+
This implements rules §6.1. If P(ʔ)=0.35 and P(k)=0.40 in a frame, the `Q` column holds 0.75, and neither member is treated as a competitor of the other.
|
| 160 |
+
|
| 161 |
+
### 4.4 Tokenisation
|
| 162 |
+
|
| 163 |
+
`tokenize()` turns the archified IPA into a list of `TargetToken(phoneme, id, word, sound)`. Adjacent tokens with the same (canonical) id collapse into one, as in CTC. That means geminates (`m m`) become one token, and so do two neighbouring phonemes that map to the same sound. Gemination is therefore not scored (§6.3 #10).
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
## 5. Choosing the variant: CTC forced alignment
|
| 168 |
+
|
| 169 |
+
For every expanded variant, [app/align.py](../app/align.py) `ctc_viterbi()` runs the standard CTC Viterbi over the extended label sequence `blank, y1, blank, y2, …, yN, blank` (S = 2N+1 states). It allows stay, advance by one, and the skip `s−2 → s` when the label differs from `s−2`. The path must end on the last label or the trailing blank.
|
| 170 |
+
|
| 171 |
+
The result is `total_logp` (the best path's log-probability) and `spans[i] = [start, end)`, the frames where token *i* is emitted.
|
| 172 |
+
|
| 173 |
+
```mermaid
|
| 174 |
+
stateDiagram-v2
|
| 175 |
+
direction LR
|
| 176 |
+
[*] --> b0
|
| 177 |
+
[*] --> y1
|
| 178 |
+
b0 --> b0
|
| 179 |
+
b0 --> y1
|
| 180 |
+
y1 --> y1
|
| 181 |
+
y1 --> b1
|
| 182 |
+
y1 --> y2: skip if y2 ≠ y1
|
| 183 |
+
b1 --> b1
|
| 184 |
+
b1 --> y2
|
| 185 |
+
y2 --> y2
|
| 186 |
+
y2 --> b2
|
| 187 |
+
b2 --> b2
|
| 188 |
+
y2 --> [*]
|
| 189 |
+
b2 --> [*]
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
The variant with the highest `total_logp` wins (not length-normalised; all variants are scored against the same T frames). If T < N for every variant, the result is `error: too_short`.
|
| 193 |
+
|
| 194 |
+
> Example: قديش said with *k* aligns better to `Q a d d eː ʃ` than to an alternative wording. Because `Q` pools ʔ/k/q, the *k* realisation costs nothing.
|
| 195 |
+
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
## 6. Per-token scoring
|
| 199 |
+
|
| 200 |
+
Each token goes through a fixed sequence of stages. The teacher's diagnostic view (`trace.tokens[i].stages`) reports the score after each one.
|
| 201 |
+
|
| 202 |
+
```mermaid
|
| 203 |
+
flowchart LR
|
| 204 |
+
G["1 · GOP<br/>score_tokens"] --> X["2 · Exemption<br/>max(score, floor)"]
|
| 205 |
+
X --> I["3 · Insertion cap<br/>min(score, 60)"]
|
| 206 |
+
I --> B["4 · Native baseline<br/>100·s / b"]
|
| 207 |
+
B --> C["5 · Calibration floor<br/>max(score, floor)"]
|
| 208 |
+
C --> F{"6 · Flag?<br/>score under 50<br/>AND beyond margin<br/>AND consonant or flagged vowel"}
|
| 209 |
+
F -->|yes| T["tip (by rule)"]
|
| 210 |
+
F -->|no| N["maybe an 'issue' if under 90"]
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
### 6.1 Stage 1: GOP (`score_tokens`)
|
| 214 |
+
|
| 215 |
+
**Window.** Token *i*'s window runs from the end of token *i−1* to the start of token *i+1* (clamped to include its own span). It therefore contains the blank frames around the emission, which is where a substituted phoneme typically peaks when the path was forced through the target.
|
| 216 |
+
|
| 217 |
+
**Target peak.** `peak = max_t logp[t, target]` inside the window. For a class token this is the pooled column. `realised` names the member with the highest peak (used for dialect detection and display).
|
| 218 |
+
|
| 219 |
+
**Competitor.** Over the real columns only (no virtual columns), take each column's max inside the window. Then mask:
|
| 220 |
+
- the blank,
|
| 221 |
+
- the labels of tokens *i−1*, *i*, *i+1* and all their class members.
|
| 222 |
+
|
| 223 |
+
Neighbours are masked because coarticulation into the adjacent phoneme is not an error (§6.3 #11). The argmax of what remains is the competitor, and its max is `comp_peak`. If the competitor is outside the set of sounds the system can name (Arabic, Hebrew interference sounds, rule labels), it is reported as `other` instead of by name. Example: a click or a retroflex from the multilingual vocab.
|
| 224 |
+
|
| 225 |
+
**Score.**
|
| 226 |
+
|
| 227 |
+
$$
|
| 228 |
+
\text{gop} = \log p_{\text{target}} - \max(\log p_{\text{target}},\ \log p_{\text{comp}}) \;\le 0
|
| 229 |
+
$$
|
| 230 |
+
|
| 231 |
+
$$
|
| 232 |
+
\text{confidence} = \min\!\left(1,\ \frac{p_{\text{target}}}{0.30}\right)
|
| 233 |
+
$$
|
| 234 |
+
|
| 235 |
+
$$
|
| 236 |
+
\text{score} = 100 \cdot e^{\,\lambda\cdot \text{gop}} \cdot \sqrt{\text{confidence}}, \qquad \lambda = 0.5\ (\texttt{leniency})
|
| 237 |
+
$$
|
| 238 |
+
|
| 239 |
+
- With λ = 0.5 the ratio is square-rooted: a competitor twice as likely as the target gives 71, not 50. The zero-shot model is noisy on accented speech, so a linear ratio would punish the model's own uncertainty.
|
| 240 |
+
- The confidence term penalises a target that wins but only weakly. Below a peak posterior of 0.30, the score shrinks with √(p/0.3).
|
| 241 |
+
- A score of 100 requires the target to beat every non-neighbour competitor and reach p ≥ 0.30.
|
| 242 |
+
|
| 243 |
+
**Deletion.** `deleted = p_target < 0.05 AND p_comp < 0.20`: nothing clearly there, target or otherwise. A deleted token is reported as heard `_`.
|
| 244 |
+
|
| 245 |
+
**Worked numbers.** ħ said as a Hebrew ח (χ), with p(ħ) = 0.04 and p(χ) = 0.85 in the window:
|
| 246 |
+
|
| 247 |
+
| quantity | value |
|
| 248 |
+
|---|---|
|
| 249 |
+
| gop | ln(0.04 / 0.85) = −3.056 |
|
| 250 |
+
| e^(0.5·gop) | 0.217 |
|
| 251 |
+
| confidence | 0.04 / 0.30 = 0.133, √ = 0.365 |
|
| 252 |
+
| score | 100 · 0.217 · 0.365 ≈ **7.9** |
|
| 253 |
+
| deleted? | no (p_comp 0.85 ≥ 0.20) |
|
| 254 |
+
|
| 255 |
+
### 6.2 Stage 2: exemptions (rules §6.3)
|
| 256 |
+
|
| 257 |
+
These are cases the model hears as a mistake that are ordinary native speech. The first rule that applies wins ([app/rules.py](../app/rules.py) `EXEMPTIONS`):
|
| 258 |
+
|
| 259 |
+
| Exemption | Condition | Result |
|
| 260 |
+
|---|---|---|
|
| 261 |
+
| `glottal_dropped` | token deleted and its sound is ʔ or `Q` | 100 |
|
| 262 |
+
| `article_vowel_elided` | deleted vowel of ال right after a word ending in a vowel | 100 |
|
| 263 |
+
| `near_vowel` | vowel heard as same-group neighbour (i/e, u/o, a/ə/e) or other length | ≥ 90 (`near_vowel_floor`) |
|
| 264 |
+
|
| 265 |
+
A glottal stop replaced by another consonant is not exempt (only a deletion is), unless the class accepts it (ق → k).
|
| 266 |
+
|
| 267 |
+
### 6.3 Stage 3: vowel insertion inside a cluster (rule 3.10)
|
| 268 |
+
|
| 269 |
+
Hebrew speakers often break a word-internal consonant cluster with an epenthetic vowel (*katiir* for *ktiir*). For each pair of adjacent consonants in the same word (not the sentence's first cluster, and not when either is w or j), the scorer looks at the greedy (argmax) frames between the two consonants' spans. If at least `min_frames` = 2 frames have a vowel as argmax with p ≥ `min_peak` = 0.5, the second consonant's score is capped at `score_cap` = 60, and a severity-1 tip is attached.
|
| 270 |
+
|
| 271 |
+
### 6.4 Stage 4: native baseline
|
| 272 |
+
|
| 273 |
+
The phoneme model mishears some sounds even from natives (ʕ in على heard as dropped, l as r). A learner should not lose points for the model's weaknesses.
|
| 274 |
+
|
| 275 |
+
[app/reference.py](../app/reference.py) synthesises the phrase's first variant with a Palestinian TTS voice and scores it with the same `Scorer`. It keeps the per-sound scores, unless the fit is `unrelated` or the mean phoneme score is under `ref_min_mean` = 60, in which case the voice is not trusted. One voice is generated per phrase. Further voices are used only if already cached from playback. The score request waits up to 8 s for a reference, then proceeds without one.
|
| 276 |
+
|
| 277 |
+
Learner tokens are matched to reference tokens with `difflib.SequenceMatcher` on `(word, sound)` pairs. Sounds compare by symbol, so a reference that said ق as q is a valid baseline for a learner who says ʔ. Per learner token, `b` is the minimum score over all references that share that sound. Then:
|
| 278 |
+
|
| 279 |
+
$$
|
| 280 |
+
s' = \begin{cases} s & b \text{ is None or } b \ge 100 \\ \min\!\left(100,\ 100\cdot s / \max(b, 1)\right) & \text{otherwise} \end{cases}
|
| 281 |
+
$$
|
| 282 |
+
|
| 283 |
+
So a sound the TTS voice scores 40 on and the learner scores 40 on becomes 100. The baseline only ever raises scores. A word whose sounds were raised this way, and now all score ≥ 90, also shows the expected transliteration as "heard": the visible difference is one a native produces too.
|
| 284 |
+
|
| 285 |
+
### 6.5 Stage 5: teacher calibration floors
|
| 286 |
+
|
| 287 |
+
Confirmed tutor flags can become calibration rules `(expected, heard, phrase?) → floor`. A rule is looked up by sound symbol first, then by written phoneme, phrase-specific before global. It raises a matching token to `floor` and never lowers it. Applied floors are listed in `result.calibrated`.
|
| 288 |
+
|
| 289 |
+
### 6.6 Stage 6: is it a mistake? Flags, margins, rules
|
| 290 |
+
|
| 291 |
+
Final per-token scores now exist. The decision to **flag** a token (show "heard as X" and a tip) needs all three of:
|
| 292 |
+
|
| 293 |
+
1. `score < flag_below` (50).
|
| 294 |
+
2. The competitor beats the target by at least the pair's **margin** (rules §6.2):
|
| 295 |
+
- substitution: `−gop ≥ margin(target, heard)`
|
| 296 |
+
- deletion: `p_target ≤ deleted_peak − margin(target, "_")` in log space
|
| 297 |
+
3. The token is a consonant, or a short vowel a rule explicitly flags. Currently that is only **u after a consonant in the same word** (rule 3.11). Other short vowels are never flagged (§6.3 #7).
|
| 298 |
+
|
| 299 |
+
Margins are natural-log ratios from the rules document:
|
| 300 |
+
|
| 301 |
+
| Target | Heard | Margin | Meaning |
|
| 302 |
+
|---|---|---|---|
|
| 303 |
+
| sˤ tˤ dˤ zˤ ðˤ | s t d z ð | 0.7 | plain must be ≈ 2× likelier (emphasis is weak in the base model) |
|
| 304 |
+
| h | `_` | 0.7 | h is short and quiet; natives reduce it too |
|
| 305 |
+
| ħ | h | 0.4 | ≈ 1.5× |
|
| 306 |
+
| long vowels | short | 1.1 | declared, but see the note below |
|
| 307 |
+
| everything else | | 0.0 | the competitor merely has to win |
|
| 308 |
+
|
| 309 |
+
> Margins on long-vowel targets are skipped at parse time (`"ː" not in t`), because `base()` drops length and the margin would otherwise land on vowel-quality pairs it was not meant for.
|
| 310 |
+
|
| 311 |
+
The **rule** for a substitution is looked up as `(target, heard)` in the `checks` of [static/assets/pronunciation-rules.md](../static/assets/pronunciation-rules.md). Example:
|
| 312 |
+
|
| 313 |
+
```yaml
|
| 314 |
+
id: pharyngeal_h
|
| 315 |
+
checks:
|
| 316 |
+
- {target: ħ, heard: [χ, x], severity: 3, feedback: "…"}
|
| 317 |
+
- {target: ħ, heard: [h, ɦ], severity: 2, feedback: "…"}
|
| 318 |
+
- {target: ħ, heard: ["_"], severity: 2, feedback: "…"}
|
| 319 |
+
```
|
| 320 |
+
|
| 321 |
+
Severity 3 means the error changes meaning (ħ/χ, ʕ/ʔ); 1 is accent. The rule chooses the Hebrew feedback text and the tip ordering. **It does not change the number.** The score is pure acoustics plus the stages above.
|
| 322 |
+
|
| 323 |
+
---
|
| 324 |
+
|
| 325 |
+
## 7. From tokens to words, letters and the sentence
|
| 326 |
+
|
| 327 |
+
```mermaid
|
| 328 |
+
flowchart BT
|
| 329 |
+
P1["phoneme scores<br/>(vowels weight 0.5)"] --> WS["word score<br/>0.5 · min + 0.5 · mean"]
|
| 330 |
+
P1 --> PM["phone mean<br/>weighted"]
|
| 331 |
+
WS --> WM["word mean"]
|
| 332 |
+
PM --> SS["sentence = 0.5 · phone mean + 0.5 · word mean"]
|
| 333 |
+
WM --> SS
|
| 334 |
+
SS --> UC{"unrelated?"}
|
| 335 |
+
UC -->|yes| CAP["min(score, 30)"]
|
| 336 |
+
UC -->|no| R["round → 0–100"]
|
| 337 |
+
CAP --> R
|
| 338 |
+
P1 --> LT["letter colour<br/>= weakest of its sounds"]
|
| 339 |
+
```
|
| 340 |
+
|
| 341 |
+
**Word.** `word = 0.5 · min(phonemes) + 0.5 · mean(phonemes)` (`word_min_weight`). One bad sound drags its word down hard, but not to zero.
|
| 342 |
+
|
| 343 |
+
**Sentence.**
|
| 344 |
+
|
| 345 |
+
$$
|
| 346 |
+
\text{phone\_mean} = \frac{\sum_i w_i s_i}{\sum_i w_i},\quad w_i = \begin{cases}0.5 & \text{vowel}\\ 1 & \text{consonant}\end{cases}
|
| 347 |
+
\qquad
|
| 348 |
+
\text{overall} = 0.5\cdot\text{phone\_mean} + 0.5\cdot\overline{\text{word}}
|
| 349 |
+
$$
|
| 350 |
+
|
| 351 |
+
The word term exists so that a single wrong consonant moves a sentence from "excellent" to "almost". A plain phoneme mean would dilute it across all the correct sounds.
|
| 352 |
+
|
| 353 |
+
**Letters.** For display, the Hebrew-letter transliteration of each word is mapped back to the tokens that carry each letter (`_carriers`, `word_letters`). Each letter gets the minimum score among its tokens. A letter under `LETTER_GOOD` = 75 also carries `why` (what the sound was heard as) and `tip`. The page colours letters ≥ 75 / ≥ 50 / below.
|
| 354 |
+
|
| 355 |
+
### 7.1 Worked example
|
| 356 |
+
|
| 357 |
+
A hypothetical phrase `w eː n | i l ħ a m m aː m` ("where is the bathroom"), with ħ said as χ as in §6.1. Geminate `m m` is one token.
|
| 358 |
+
|
| 359 |
+
| word | tokens and scores | mean | min | word score |
|
| 360 |
+
|---|---|---|---|---|
|
| 361 |
+
| ween | w 100 · eː 95 · n 100 | 98.3 | 95 | 96.7 |
|
| 362 |
+
| ilħammaam | i 100 · l 100 · **ħ 7.9** · a 96 · m 100 · aː 92 · m 100 | 85.1 | 7.9 | **46.5** |
|
| 363 |
+
|
| 364 |
+
- phone_mean = (5·100 + 7.9 + 0.5·(95+100+96+92)) / (6 + 4·0.5) = 699.4 / 8 = **87.4**
|
| 365 |
+
- word_mean = (96.7 + 46.5) / 2 = **71.6**
|
| 366 |
+
- overall = 0.5·87.4 + 0.5·71.6 = **79.5 → 80**, which the page labels "כמעט" (almost).
|
| 367 |
+
|
| 368 |
+
With phoneme averaging alone, the same recording would score 87 and be called excellent.
|
| 369 |
+
|
| 370 |
+
ħ is flagged: 7.9 < 50, the margin for (ħ, χ) is 0, so −gop = 3.06 ≥ 0, and ħ is a consonant. The rule `pharyngeal_h` gives a severity-3 tip.
|
| 371 |
+
|
| 372 |
+
---
|
| 373 |
+
|
| 374 |
+
## 8. "Different sentence?" detection
|
| 375 |
+
|
| 376 |
+
Forced alignment always produces a path, even for the wrong sentence. To catch this, the scorer greedy-decodes the posteriors (argmax per frame, collapse repeats, drop blanks; real columns only). It then computes the Levenshtein distance to the chosen variant's token ids, with class members counting as equal.
|
| 377 |
+
|
| 378 |
+
$$
|
| 379 |
+
\text{unrelated} = \frac{\text{edit\_distance}}{N_{\text{tokens}}} > 0.65
|
| 380 |
+
$$
|
| 381 |
+
|
| 382 |
+
If unrelated, the sentence score is capped at 30, no tips or issues are produced, letters get no reasons, and the page shows "משפט אחר?". It also covers hesitations and restarts (§6.3 #14). The greedy decode is returned as `heard_ipa`. The Hebrew-letter rendering of what was heard (`heard_tr`) is built by cutting the greedy sequence at the aligned word starts.
|
| 383 |
+
|
| 384 |
+
---
|
| 385 |
+
|
| 386 |
+
## 9. Feedback selection
|
| 387 |
+
|
| 388 |
+
```mermaid
|
| 389 |
+
flowchart TD
|
| 390 |
+
A["Flagged tokens"] --> B["tip_for(sound, heard, rule)<br/>dedupe by tip key"]
|
| 391 |
+
B --> C["sort: severity desc, then score asc"]
|
| 392 |
+
C --> D["keep max_tips = 3"]
|
| 393 |
+
E["All tokens under 100 without a tip"] --> F["keep those under 90 AND beyond margin"]
|
| 394 |
+
F -->|none and no tips| G["the 3 weakest"]
|
| 395 |
+
F --> H["issues, up to 8, weakest first"]
|
| 396 |
+
G --> H
|
| 397 |
+
```
|
| 398 |
+
|
| 399 |
+
- **Tips** are the flagged substitutions with a rule, most severe first (meaning-changing before accent), then lowest score. At most 3 (4 with the `tips_by_severity` toggle off).
|
| 400 |
+
- **Issues** explain any score short of 100. Every imprecise sound not already covered by a tip, so the student never sees 94 with no reason.
|
| 401 |
+
- **Dialect.** If the accepted realisations used are all urban-only or all rural-only, the result carries `dialect`. If they disagree, it carries `mixed_dialect`. Either way this is informational and never affects the score (§6.3 #12).
|
| 402 |
+
|
| 403 |
+
---
|
| 404 |
+
|
| 405 |
+
## 10. The response
|
| 406 |
+
|
| 407 |
+
```jsonc
|
| 408 |
+
{
|
| 409 |
+
"score": 80, // sentence, 0–100
|
| 410 |
+
"variant": {"ar": "...", "tr": "...", "ipa": "..."}, // the chosen accepted variant
|
| 411 |
+
"heard_ipa": "w e n i l χ a m a m", // greedy decode
|
| 412 |
+
"heard_tr": "...", // the same in Hebrew letters
|
| 413 |
+
"unrelated": false,
|
| 414 |
+
"words": [{
|
| 415 |
+
"ar": "...", "tr": "...", "heard_tr": "...", "score": 46.5,
|
| 416 |
+
"letters": [{"t": "חַ", "score": 7.9, "why": "...", "tip": "..."}],
|
| 417 |
+
"phonemes": [{"ipa": "ħ", "word": 1, "score": 7.9, "flagged": true, "heard": "χ"}]
|
| 418 |
+
}],
|
| 419 |
+
"tips": [{"key": "...", "title": "...", "text": "...", "severity": 3, "word": "...", "score": 8}],
|
| 420 |
+
"issues": [{"sound": "...", "word_tr": "...", "score": 92, "missing": false, "heard": "..."}],
|
| 421 |
+
"references": 1, // native baselines used
|
| 422 |
+
"dialect": "urban", // or "mixed_dialect": true
|
| 423 |
+
"calibrated": [ ... ], // only if a teacher floor applied
|
| 424 |
+
"phoneme_heatmap": { ... }, // teachers only: max-pooled posteriors, ≤ 180 columns
|
| 425 |
+
"trace": { ... } // teachers only: every stage, threshold and intermediate value
|
| 426 |
+
}
|
| 427 |
+
```
|
| 428 |
+
|
| 429 |
+
`trace` is the place to start when debugging a surprising score. It holds the candidate variants with their path log-probs, the unrelated ratio, and per token: window, p_target, competitor, gop, confidence factor, each stage's score, margin needed vs. got, rule, and whether the token became a tip or an issue.
|
| 430 |
+
|
| 431 |
+
---
|
| 432 |
+
|
| 433 |
+
## 11. From a recording's score to exercise grades
|
| 434 |
+
|
| 435 |
+
The scorer grades one recording. Everything above that level is aggregation, mostly on the client.
|
| 436 |
+
|
| 437 |
+
```mermaid
|
| 438 |
+
flowchart TD
|
| 439 |
+
R["Recording score 0–100"] --> BAND["Verdict on the card<br/>≥ 85 מצוין · ≥ 60 כמעט · else נסו שוב<br/>unrelated → משפט אחר?"]
|
| 440 |
+
R --> BEST["Sentence best = max over attempts<br/>(device + attempts table)"]
|
| 441 |
+
BEST --> TOPIC["Topic: done when every sentence has a best<br/>★ gold when all bests ≥ 85"]
|
| 442 |
+
TOPIC --> LESSON["Lesson / homework progress"]
|
| 443 |
+
BEST --> PANEL["Teacher panel: student grade = mean of bests<br/>class 'hard sentences' = class mean of bests under 70"]
|
| 444 |
+
BEST --> DAILY["Daily session review pool: bests under 85"]
|
| 445 |
+
R --> QUIZ["Story quiz: mean of line scores said<br/>+ top 3 mistakes by frequency"]
|
| 446 |
+
R --> VERB["Verb game L4–5: ≥ 75 correct<br/>unrelated / too short → uncertain, not counted"]
|
| 447 |
+
VERB --> GATE["Level gates: share correct ≥ 85–90 %"]
|
| 448 |
+
```
|
| 449 |
+
|
| 450 |
+
| Exercise | Grade | Where |
|
| 451 |
+
|---|---|---|
|
| 452 |
+
| Single sentence | The recording's `score`; band 85 / 60 | `index.html` (`EXCELLENT = 85`) |
|
| 453 |
+
| Sentence progress | Best score over all attempts. Any non-empty recording, including an unrelated one, marks the sentence as practised | `keepScore` |
|
| 454 |
+
| Topic | Practised count / total; gold if every best ≥ 85 | `tally()` |
|
| 455 |
+
| Story quiz ("בוחן") | Arithmetic mean of the lines' scores. One recording per line, no retries | `quizEnd()` |
|
| 456 |
+
| Verb game, levels 4–5 | `correct` if score ≥ 75. `unrelated` or too short → `outcome: "uncertain"`, `counts_as_attempt: false`, never a grammar error | [app/speech_evaluation.py](../app/speech_evaluation.py) |
|
| 457 |
+
| Verb game, unlocking | Correct ratio ≥ `VERB_THRESH` (0.85–0.90) over a minimum number of attempts | `index.html` |
|
| 458 |
+
| Arabic → Hebrew translation | Not scored by this pipeline: browser `he-IL` ASR, share of expected words heard in order, 0.8 = correct. Not stored | `index.html` (`HE`) |
|
| 459 |
+
| Teacher panel | Student grade = mean of their best scores; class difficulty per sentence = mean of students' bests | `index.html`, `/api/class` |
|
| 460 |
+
|
| 461 |
+
Verb game levels 1–3 (choice and matching) are graded server-side by exact match against a stored answer, with no audio involved.
|
| 462 |
+
|
| 463 |
+
---
|
| 464 |
+
|
| 465 |
+
## 12. Configuration reference
|
| 466 |
+
|
| 467 |
+
The values live in code, not in the rules document. The `scoring:` block of `pronunciation-rules.md` is read for **`margins`, `margin_default` and `max_tips` only**. Its other keys (`flag_below`, `bands`, `sentence_score`, `severity_weight`, …) are documentation. Its `sentence_score` string (`mean(word_min)`) also describes an older word formula; the code uses `0.5·min + 0.5·mean`.
|
| 468 |
+
|
| 469 |
+
| Knob | Default | Effect |
|
| 470 |
+
|---|---|---|
|
| 471 |
+
| `GopConfig.leniency` | 0.5 | exponent on the target/competitor ratio |
|
| 472 |
+
| `GopConfig.confident_peak` | 0.30 | target posterior above which confidence stops lowering the score |
|
| 473 |
+
| `GopConfig.deleted_peak` / `deleted_comp` | 0.05 / 0.20 | deletion thresholds |
|
| 474 |
+
| `ScorerConfig.flag_below` | 50 | max score for a flag and tip |
|
| 475 |
+
| `ScorerConfig.issue_below` | 90 | max score to be listed as an issue |
|
| 476 |
+
| `ScorerConfig.near_vowel_floor` | 90 | floor for neighbouring vowel qualities |
|
| 477 |
+
| `ScorerConfig.word_min_weight` | 0.5 | weight of the weakest phoneme in a word |
|
| 478 |
+
| `ScorerConfig.unrelated_ratio` | 0.65 | edit-distance ratio for "different sentence" |
|
| 479 |
+
| `ScorerConfig.ref_min_mean` | 60 | minimum mean for a TTS reference to be trusted |
|
| 480 |
+
| vowel weight | 0.5 | in the sentence phone mean (hard-coded) |
|
| 481 |
+
| unrelated cap | 30 | hard-coded |
|
| 482 |
+
| `LETTER_GOOD` | 75 | letter colour / reason threshold |
|
| 483 |
+
| insertion `min_peak`, `min_frames`, `score_cap` | 0.5, 2, 60 | rule 3.10, from the rules document |
|
| 484 |
+
|
| 485 |
+
Teachers can switch off each rule-derived behaviour at runtime (the **ניטור** tab, `rules.TOGGLES`). Switching one off restores the pre-rules behaviour for that aspect from the next recording on.
|
| 486 |
+
|
| 487 |
+
---
|
| 488 |
+
|
| 489 |
+
## 13. Known limitations
|
| 490 |
+
|
| 491 |
+
- **Zero-shot model.** The base model was not trained on Palestinian Arabic or Hebrew-accented speech. The native baseline and margins compensate partially; fine-tuning ([train/finetune.py](../train/finetune.py)) on teacher and labelled learner recordings is the intended fix.
|
| 492 |
+
- **Duration is not modelled.** CTC posteriors are peaky and collapse repeats, so vowel length and gemination are effectively unscored.
|
| 493 |
+
- **Fallback symbols.** If the vocab lacks a phoneme (e.g. `ðˤ`), it is scored as its stand-in. `/api/health` → `phoneme_fallbacks` lists these, and the contrast they lose cannot be detected.
|
| 494 |
+
- **GOP is max-pooled, not frame-summed.** Using window peaks makes the score robust to alignment jitter, but a brief spurious competitor spike can still win the window.
|
static/guide.html
CHANGED
|
@@ -376,13 +376,13 @@ a,button{-webkit-tap-highlight-color:transparent}
|
|
| 376 |
<div class="m-bar"><span class="m-btn p">→ חזרה למסלול</span><span class="m-sp"></span><span class="m-btn">☰ רשימה</span><span class="m-btn gear hl">⚙</span></div>
|
| 377 |
<div class="dlg">
|
| 378 |
<div class="dlg-h"><b>הגדרות השמעה</b><span class="m-btn">סגירה</span></div>
|
| 379 |
-
<div class="row at l"><span class="n">1</span><span>קול ההשמעה</span><span class="sel">
|
| 380 |
<div class="row at l"><span class="n">2</span><span>מהירות</span><span class="seg"><i>x0.8</i><i class="on">x1</i><i>x1.5</i></span></div>
|
| 381 |
<div class="row at l"><span class="n">3</span><span>הקראת המשפט בעברית</span><span class="tg on" aria-hidden="true"></span></div>
|
| 382 |
</div>
|
| 383 |
</div>
|
| 384 |
<ol class="key">
|
| 385 |
-
<li><span class="n">1</span><div><b>קול ההשמעה</b><span class="d">
|
| 386 |
<li><span class="n">2</span><div><b>מהירות</b><span class="d"><b style="display:inline">x0.8</b> לאט יותר, כדי לשמוע כל הברה. <b style="display:inline">x1</b> רגיל. <b style="display:inline">x1.5</b> מהיר, לאימון הבנת שמיעה.</span></div></li>
|
| 387 |
<li><span class="n">3</span><div><b>הקראת המשפט בעברית</b><span class="d">כשמסומן, כל משפט חדש מוקרא בקול בעברית לפני שעונים, כך שאפשר לתרגל בלי לקרוא מהמסך. בנושאי ערבית ← עברית אין הקראה, כי העברית היא התשובה.</span></div></li>
|
| 388 |
</ol>
|
|
|
|
| 376 |
<div class="m-bar"><span class="m-btn p">→ חזרה למסלול</span><span class="m-sp"></span><span class="m-btn">☰ רשימה</span><span class="m-btn gear hl">⚙</span></div>
|
| 377 |
<div class="dlg">
|
| 378 |
<div class="dlg-h"><b>הגדרות השמעה</b><span class="m-btn">סגירה</span></div>
|
| 379 |
+
<div class="row at l"><span class="n">1</span><span>קול ההשמעה</span><span class="sel">רַאאִף</span></div>
|
| 380 |
<div class="row at l"><span class="n">2</span><span>מהירות</span><span class="seg"><i>x0.8</i><i class="on">x1</i><i>x1.5</i></span></div>
|
| 381 |
<div class="row at l"><span class="n">3</span><span>הקראת המשפט בעברית</span><span class="tg on" aria-hidden="true"></span></div>
|
| 382 |
</div>
|
| 383 |
</div>
|
| 384 |
<ol class="key">
|
| 385 |
+
<li><span class="n">1</span><div><b>קול ההשמעה</b><span class="d"><b style="display:inline">רַאאִף</b> מקריא את כל התשובות, הסיפורים והשיחות.</span></div></li>
|
| 386 |
<li><span class="n">2</span><div><b>מהירות</b><span class="d"><b style="display:inline">x0.8</b> לאט יותר, כדי לשמוע כל הברה. <b style="display:inline">x1</b> רגיל. <b style="display:inline">x1.5</b> מהיר, לאימון הבנת שמיעה.</span></div></li>
|
| 387 |
<li><span class="n">3</span><div><b>הקראת המשפט בעברית</b><span class="d">כשמסומן, כל משפט חדש מוקרא בקול בעברית לפני שעונים, כך שאפשר לתרגל בלי לקרוא מהמסך. בנושאי ערבית ← עברית אין הקראה, כי העברית היא התשובה.</span></div></li>
|
| 388 |
</ol>
|
static/index.html
CHANGED
|
@@ -1013,7 +1013,6 @@ body.quiet :is(#mic,#playMine,#driveBtn,.phone-btn),body.driving :is(#quietBtn,#
|
|
| 1013 |
<dialog id="setDlg" aria-labelledby="setTitle">
|
| 1014 |
<div class="dlg-head"><h2 id="setTitle">הגדרות השמעה</h2><button class="btn" id="setClose">סגירה</button></div>
|
| 1015 |
<div class="voicebox">
|
| 1016 |
-
<label>קול ההשמעה<select id="voiceId"></select></label>
|
| 1017 |
<label>מהירות<select id="speed"><option value="0.8">x0.8</option><option value="1">x1</option><option value="1.5">x1.5</option></select></label>
|
| 1018 |
<label>הקראת המשפט בעברית<input type="checkbox" id="readHe"></label>
|
| 1019 |
</div>
|
|
@@ -2689,7 +2688,7 @@ function drawAnalyzedAnswer(r){
|
|
| 2689 |
pair.append(expected,heard); main.append(pair);
|
| 2690 |
});
|
| 2691 |
card.replaceChildren(mark("analysis-key","למעלה: התשובה · למטה: המערכת שמעה","small"),main);
|
| 2692 |
-
card.append(mark("ar",cur.ar,"small"));
|
| 2693 |
if(cur.read) card.append(mark("he",cur.he,"small"));
|
| 2694 |
card.classList.add("revealed","scored"); card.removeAttribute("role"); card.removeAttribute("tabindex"); card.removeAttribute("aria-label"); card.onclick=null; card.onkeydown=null;
|
| 2695 |
$("hintLabel").textContent=hasWhy(r)?WHY_HINT:"התשובה וניתוח ההגייה";
|
|
@@ -2700,7 +2699,7 @@ function drawWordReveals(){
|
|
| 2700 |
const button=mark("word-reveal","","div"); button.setAttribute("role","button"); button.tabIndex=0;
|
| 2701 |
button.setAttribute("aria-label",cur.read?"גלו את התרגום לעברית":"גלו את התרגום לערבית");
|
| 2702 |
const main=mark("",answer); setTr(main,answer); main.setAttribute("aria-hidden","true"); button.append(main);
|
| 2703 |
-
if(!cur.read){ const ar=mark("ar",cur.ar,"small"); ar.setAttribute("aria-hidden","true"); button.append(ar); }
|
| 2704 |
button.onclick=()=>revealWord(button); box.append(button);
|
| 2705 |
button.onkeydown=e=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); revealWord(button); } };
|
| 2706 |
$("hintLabel").textContent=cur.read?"לחצו על הכרטיסייה כדי לגלות את התרגום לעברית":"לחצו על הכרטיסייה כדי לגלות את התרגום לערבית";
|
|
@@ -2730,7 +2729,7 @@ $("prevBtn").onclick=()=>{ // the sentence before this one in the topic, pract
|
|
| 2730 |
};
|
| 2731 |
function arVoice(){ const vs=speechSynthesis.getVoices(); return vs.find(v=>/^ar[-_](PS|JO|LB|SY|IL)/i.test(v.lang))||vs.find(v=>/^ar/i.test(v.lang)); }
|
| 2732 |
if("speechSynthesis" in window) speechSynthesis.getVoices();
|
| 2733 |
-
const ttsUrls={}; //
|
| 2734 |
// the guide's line for a new student: until it is opened or closed once
|
| 2735 |
const guideSeen=()=>{ store.set("ihki.guideSeen",true); $("guideTip").hidden=true; };
|
| 2736 |
$("guideTip").hidden=store.get("ihki.guideSeen",false);
|
|
@@ -2740,41 +2739,49 @@ $("readHe").onchange=()=>{
|
|
| 2740 |
store.set("ihki.readHe",$("readHe").checked);
|
| 2741 |
if(!(rec&&rec.state==="recording")) promptRead=readPrompt();
|
| 2742 |
};
|
| 2743 |
-
$("voiceId").onchange=()=>store.set("ihki.voice",$("voiceId").value); // Sawtak voice of the playback; options come from the server
|
| 2744 |
// Speed of the Arabic answer. The provider has no speed setting: the browser plays the same audio slower or faster, at the same pitch.
|
| 2745 |
$("speed").value=String(store.get("ihki.speed",1)); if(!$("speed").value) $("speed").value="1";
|
| 2746 |
$("speed").onchange=()=>store.set("ihki.speed",+$("speed").value);
|
| 2747 |
const answerRate=()=>+$("speed").value||1;
|
| 2748 |
function answerPlayer(url){ const a=new Audio(url); a.preservesPitch=true; a.playbackRate=answerRate(); return a; }
|
| 2749 |
-
let
|
| 2750 |
-
|
| 2751 |
-
|
| 2752 |
-
|
| 2753 |
-
|
| 2754 |
-
|
| 2755 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2756 |
}
|
|
|
|
| 2757 |
async function answerAudio(id){ // {url} of the Sawtak answer, or null when the server voice is unavailable
|
| 2758 |
-
|
| 2759 |
-
if(!ttsOff && !ttsUrls[key]){
|
| 2760 |
try{
|
| 2761 |
-
const res=await api("/api/tts/"+id+
|
| 2762 |
if(res.ok){
|
| 2763 |
-
ttsUrls[
|
| 2764 |
}else{
|
| 2765 |
const detail=(await res.json().catch(()=>({}))).detail||res.status;
|
| 2766 |
if(detail==="tts not configured") ttsOff=true; // no key on the server: stop asking
|
| 2767 |
}
|
| 2768 |
}catch{}
|
| 2769 |
}
|
| 2770 |
-
return ttsUrls[
|
| 2771 |
}
|
| 2772 |
$("speakBtn").onclick=async()=>{
|
| 2773 |
-
const id=cur.id, btn=$("speakBtn");
|
| 2774 |
-
btn.disabled=true; const a=await answerAudio(id); btn.disabled=false;
|
| 2775 |
-
if(a){
|
|
|
|
|
|
|
|
|
|
| 2776 |
if(!("speechSynthesis" in window)) return;
|
| 2777 |
-
const u=new SpeechSynthesisUtterance(cur.ar); const v=arVoice();
|
| 2778 |
if(v){u.voice=v;u.lang=v.lang;} else u.lang="ar-SA";
|
| 2779 |
u.rate=.85*answerRate(); speechSynthesis.cancel(); speechSynthesis.speak(u);
|
| 2780 |
};
|
|
@@ -3044,6 +3051,7 @@ function drawScoringRules(box){
|
|
| 3044 |
function drawFlags(){
|
| 3045 |
const box=$("mFlags"); box.innerHTML=""; for(const url of flagAudioUrls) URL.revokeObjectURL(url); flagAudioUrls=[];
|
| 3046 |
drawActivity(box);
|
|
|
|
| 3047 |
if(!flagsLoaded){ box.append(mark("card","בחרו בניטור כדי לטעון את הנתונים.")); return; }
|
| 3048 |
drawScoringRules(box);
|
| 3049 |
if(flagsError){ const card=mark("card",""); card.append(mark("","לא ניתן לטעון את הדיווחים","h2"),mark("",flagsError,"p")); box.append(card); return; }
|
|
@@ -3128,6 +3136,23 @@ function drawFlags(){
|
|
| 3128 |
confirm.onclick=()=>save("confirmed"); reject.onclick=()=>save("rejected"); actions.append(category,confirm,reject,msg); card.append(note,actions); box.append(card);
|
| 3129 |
}
|
| 3130 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3131 |
function drawActivity(box){
|
| 3132 |
const days=activityHours===168, range=days?"7 הימים":"24 השעות", bucket=days?"שעה":"15 דקות";
|
| 3133 |
const card=mark("card activity",""), head=mark("activity-head",""), title=mark("",`פעילות ב־${range} האחרונים`,"h2"), sub=mark("",`כל עמודה מייצגת ${bucket} · מתעדכן פעם בדקה`,"p");
|
|
@@ -3194,7 +3219,7 @@ function drawContent(){
|
|
| 3194 |
const guides=mark("guides","מדריכים:","div");
|
| 3195 |
for(const a of $("guideDlg").querySelectorAll("article")){ const b=mark("btn",a.dataset.title,"button"); b.onclick=()=>showGuide(a.dataset.guide); guides.append(b); }
|
| 3196 |
box.append(guides);
|
| 3197 |
-
const voices=[
|
| 3198 |
const adder=(adds,label,fields,note,submit)=>{ // a closed form under a lesson, or under a level's daily videos
|
| 3199 |
const add=document.createElement("details"), form=document.createElement("form"), row=mark("row",""), msg=mark("msg","");
|
| 3200 |
add.className="vadd"; add.append(mark("btn",label,"summary"),form); form.className="cform"; msg.setAttribute("role","alert");
|
|
@@ -3901,8 +3926,6 @@ const ready=fetch("/api/phrases").then(r=>r.json()).then(d=>{
|
|
| 3901 |
ALL_LEVELS=d.levels||[]; ALL_LESSONS=d.lessons; ALL_CATS=d.categories; GAMES=d.games||[]; PHRASES=d.phrases;
|
| 3902 |
$("nav").querySelector('[data-view="games"]').hidden=!GAMES.some(game=>!game.hidden);
|
| 3903 |
for(const c of ALL_CATS) c.name=c.name.replace(/ ← /g,"\u00a0←\u00a0"); // the arrow never ends a line
|
| 3904 |
-
for(const v of d.voices||[]) $("voiceId").add(new Option(v.name,v.id));
|
| 3905 |
-
const saved=store.get("ihki.voice",""); if((d.voices||[]).some(v=>v.id===saved)) $("voiceId").value=saved;
|
| 3906 |
useLessons(MY_LESSONS);
|
| 3907 |
startCat(); opened=true;
|
| 3908 |
}).catch(()=>{ $("prompt").textContent="השרת לא זמין."; });
|
|
|
|
| 1013 |
<dialog id="setDlg" aria-labelledby="setTitle">
|
| 1014 |
<div class="dlg-head"><h2 id="setTitle">הגדרות השמעה</h2><button class="btn" id="setClose">סגירה</button></div>
|
| 1015 |
<div class="voicebox">
|
|
|
|
| 1016 |
<label>מהירות<select id="speed"><option value="0.8">x0.8</option><option value="1">x1</option><option value="1.5">x1.5</option></select></label>
|
| 1017 |
<label>הקראת המשפט בעברית<input type="checkbox" id="readHe"></label>
|
| 1018 |
</div>
|
|
|
|
| 2688 |
pair.append(expected,heard); main.append(pair);
|
| 2689 |
});
|
| 2690 |
card.replaceChildren(mark("analysis-key","למעלה: התשובה · למטה: המערכת שמעה","small"),main);
|
| 2691 |
+
card.append(mark("ar",cur.ar_pointed||cur.ar,"small"));
|
| 2692 |
if(cur.read) card.append(mark("he",cur.he,"small"));
|
| 2693 |
card.classList.add("revealed","scored"); card.removeAttribute("role"); card.removeAttribute("tabindex"); card.removeAttribute("aria-label"); card.onclick=null; card.onkeydown=null;
|
| 2694 |
$("hintLabel").textContent=hasWhy(r)?WHY_HINT:"התשובה וניתוח ההגייה";
|
|
|
|
| 2699 |
const button=mark("word-reveal","","div"); button.setAttribute("role","button"); button.tabIndex=0;
|
| 2700 |
button.setAttribute("aria-label",cur.read?"גלו את התרגום לעברית":"גלו את התרגום לערבית");
|
| 2701 |
const main=mark("",answer); setTr(main,answer); main.setAttribute("aria-hidden","true"); button.append(main);
|
| 2702 |
+
if(!cur.read){ const ar=mark("ar",cur.ar_pointed||cur.ar,"small"); ar.setAttribute("aria-hidden","true"); button.append(ar); }
|
| 2703 |
button.onclick=()=>revealWord(button); box.append(button);
|
| 2704 |
button.onkeydown=e=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); revealWord(button); } };
|
| 2705 |
$("hintLabel").textContent=cur.read?"לחצו על הכרטיסייה כדי לגלות את התרגום לעברית":"לחצו על הכרטיסייה כדי לגלות את התרגום לערבית";
|
|
|
|
| 2729 |
};
|
| 2730 |
function arVoice(){ const vs=speechSynthesis.getVoices(); return vs.find(v=>/^ar[-_](PS|JO|LB|SY|IL)/i.test(v.lang))||vs.find(v=>/^ar/i.test(v.lang)); }
|
| 2731 |
if("speechSynthesis" in window) speechSynthesis.getVoices();
|
| 2732 |
+
const ttsUrls={}; // phrase id -> {url} of Raef's server-generated answer
|
| 2733 |
// the guide's line for a new student: until it is opened or closed once
|
| 2734 |
const guideSeen=()=>{ store.set("ihki.guideSeen",true); $("guideTip").hidden=true; };
|
| 2735 |
$("guideTip").hidden=store.get("ihki.guideSeen",false);
|
|
|
|
| 2739 |
store.set("ihki.readHe",$("readHe").checked);
|
| 2740 |
if(!(rec&&rec.state==="recording")) promptRead=readPrompt();
|
| 2741 |
};
|
|
|
|
| 2742 |
// Speed of the Arabic answer. The provider has no speed setting: the browser plays the same audio slower or faster, at the same pitch.
|
| 2743 |
$("speed").value=String(store.get("ihki.speed",1)); if(!$("speed").value) $("speed").value="1";
|
| 2744 |
$("speed").onchange=()=>store.set("ihki.speed",+$("speed").value);
|
| 2745 |
const answerRate=()=>+$("speed").value||1;
|
| 2746 |
function answerPlayer(url){ const a=new Audio(url); a.preservesPitch=true; a.playbackRate=answerRate(); return a; }
|
| 2747 |
+
let playbackContext=null;
|
| 2748 |
+
function unlockPlayback(){
|
| 2749 |
+
try{
|
| 2750 |
+
playbackContext=playbackContext||new (window.AudioContext||window.webkitAudioContext)();
|
| 2751 |
+
playbackContext.resume(); return playbackContext;
|
| 2752 |
+
}catch{return null}
|
| 2753 |
+
}
|
| 2754 |
+
async function playAnswer(recording,context){
|
| 2755 |
+
const audio=answerPlayer(recording.url);
|
| 2756 |
+
try{ await audio.play(); return; }catch(error){ if(!context) throw error; }
|
| 2757 |
+
const buffer=await context.decodeAudioData(await recording.blob.arrayBuffer());
|
| 2758 |
+
const source=context.createBufferSource(); source.buffer=buffer; source.playbackRate.value=answerRate();
|
| 2759 |
+
source.connect(context.destination); source.start();
|
| 2760 |
}
|
| 2761 |
+
let ttsOff=false; // server has no TTS key: stop asking
|
| 2762 |
async function answerAudio(id){ // {url} of the Sawtak answer, or null when the server voice is unavailable
|
| 2763 |
+
if(!ttsOff && !ttsUrls[id]){
|
|
|
|
| 2764 |
try{
|
| 2765 |
+
const res=await api("/api/tts/"+id+"?v=5");
|
| 2766 |
if(res.ok){
|
| 2767 |
+
const blob=await res.blob(); ttsUrls[id]={blob,url:URL.createObjectURL(blob)};
|
| 2768 |
}else{
|
| 2769 |
const detail=(await res.json().catch(()=>({}))).detail||res.status;
|
| 2770 |
if(detail==="tts not configured") ttsOff=true; // no key on the server: stop asking
|
| 2771 |
}
|
| 2772 |
}catch{}
|
| 2773 |
}
|
| 2774 |
+
return ttsUrls[id]||null;
|
| 2775 |
}
|
| 2776 |
$("speakBtn").onclick=async()=>{
|
| 2777 |
+
const id=cur.id, btn=$("speakBtn"), label=btn.querySelector("small"), context=unlockPlayback();
|
| 2778 |
+
btn.disabled=true; label.textContent="טוען…"; const a=await answerAudio(id); btn.disabled=false; label.textContent="השמע";
|
| 2779 |
+
if(a){
|
| 2780 |
+
if(cur.id===id) try{ await playAnswer(a,context); }catch{ $("status").textContent="הדפדפן חסם את ההשמעה. נסו שוב."; }
|
| 2781 |
+
return;
|
| 2782 |
+
}
|
| 2783 |
if(!("speechSynthesis" in window)) return;
|
| 2784 |
+
const u=new SpeechSynthesisUtterance(cur.ar_pointed||cur.ar); const v=arVoice();
|
| 2785 |
if(v){u.voice=v;u.lang=v.lang;} else u.lang="ar-SA";
|
| 2786 |
u.rate=.85*answerRate(); speechSynthesis.cancel(); speechSynthesis.speak(u);
|
| 2787 |
};
|
|
|
|
| 3051 |
function drawFlags(){
|
| 3052 |
const box=$("mFlags"); box.innerHTML=""; for(const url of flagAudioUrls) URL.revokeObjectURL(url); flagAudioUrls=[];
|
| 3053 |
drawActivity(box);
|
| 3054 |
+
drawTtsCacheControls(box);
|
| 3055 |
if(!flagsLoaded){ box.append(mark("card","בחרו בניטור כדי לטעון את הנתונים.")); return; }
|
| 3056 |
drawScoringRules(box);
|
| 3057 |
if(flagsError){ const card=mark("card",""); card.append(mark("","לא ניתן לטעון את הדיווחים","h2"),mark("",flagsError,"p")); box.append(card); return; }
|
|
|
|
| 3136 |
confirm.onclick=()=>save("confirmed"); reject.onclick=()=>save("rejected"); actions.append(category,confirm,reject,msg); card.append(note,actions); box.append(card);
|
| 3137 |
}
|
| 3138 |
}
|
| 3139 |
+
function drawTtsCacheControls(box){
|
| 3140 |
+
const card=mark("card",""), title=mark("","מטמון הקלטות Sawtak","h2"), text=mark("","מחיקה גורמת ליצירה מחדש של הקלטות התשובה בהשמעה הבאה. גם הקלטות שנבחרו ידנית יימחקו.","p");
|
| 3141 |
+
const row=mark("row",""), button=mark("btn","מחיקת מטמון ההקלטות","button"), msg=mark("msg","");
|
| 3142 |
+
button.onclick=async()=>{
|
| 3143 |
+
if(!confirm("למחוק את כל הקלטות התשובה מהמטמון? גם הקלטות שנבחרו ידנית יימחקו, ולא ניתן לבטל את הפעולה.")) return;
|
| 3144 |
+
button.disabled=true; msg.textContent="מוחק…";
|
| 3145 |
+
try{
|
| 3146 |
+
const res=await api("/api/tts-cache",{method:"DELETE"}), data=await res.json().catch(()=>({}));
|
| 3147 |
+
if(!res.ok) throw new Error(data.detail||res.status);
|
| 3148 |
+
for(const value of Object.values(ttsUrls)) URL.revokeObjectURL(value.url);
|
| 3149 |
+
for(const key of Object.keys(ttsUrls)) delete ttsUrls[key];
|
| 3150 |
+
msg.textContent=`נמחקו ${data.removed} קבצי מטמון.`;
|
| 3151 |
+
}catch(err){ msg.textContent="המחיקה נכשלה: "+err.message; }
|
| 3152 |
+
button.disabled=false;
|
| 3153 |
+
};
|
| 3154 |
+
row.append(button,msg); card.append(title,text,row); box.append(card);
|
| 3155 |
+
}
|
| 3156 |
function drawActivity(box){
|
| 3157 |
const days=activityHours===168, range=days?"7 הימים":"24 השעות", bucket=days?"שעה":"15 דקות";
|
| 3158 |
const card=mark("card activity",""), head=mark("activity-head",""), title=mark("",`פעילות ב־${range} האחרונים`,"h2"), sub=mark("",`כל עמודה מייצגת ${bucket} · מתעדכן פעם בדקה`,"p");
|
|
|
|
| 3219 |
const guides=mark("guides","מדריכים:","div");
|
| 3220 |
for(const a of $("guideDlg").querySelectorAll("article")){ const b=mark("btn",a.dataset.title,"button"); b.onclick=()=>showGuide(a.dataset.guide); guides.append(b); }
|
| 3221 |
box.append(guides);
|
| 3222 |
+
const voices=["ראאף"];
|
| 3223 |
const adder=(adds,label,fields,note,submit)=>{ // a closed form under a lesson, or under a level's daily videos
|
| 3224 |
const add=document.createElement("details"), form=document.createElement("form"), row=mark("row",""), msg=mark("msg","");
|
| 3225 |
add.className="vadd"; add.append(mark("btn",label,"summary"),form); form.className="cform"; msg.setAttribute("role","alert");
|
|
|
|
| 3926 |
ALL_LEVELS=d.levels||[]; ALL_LESSONS=d.lessons; ALL_CATS=d.categories; GAMES=d.games||[]; PHRASES=d.phrases;
|
| 3927 |
$("nav").querySelector('[data-view="games"]').hidden=!GAMES.some(game=>!game.hidden);
|
| 3928 |
for(const c of ALL_CATS) c.name=c.name.replace(/ ← /g,"\u00a0←\u00a0"); // the arrow never ends a line
|
|
|
|
|
|
|
| 3929 |
useLessons(MY_LESSONS);
|
| 3930 |
startCat(); opened=true;
|
| 3931 |
}).catch(()=>{ $("prompt").textContent="השרת לא זמין."; });
|
tests/test_arabic_diacritics.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.arabic_diacritics import point_arabic
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_points_short_vowels_sukun_and_long_vowel():
|
| 5 |
+
assert point_arabic("مرحبا", "m a r ħ a b a") == "مَرْحَبَا"
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_points_geminate_and_keeps_punctuation():
|
| 9 |
+
assert point_arabic("يلّا!", "j a l l a") == "يَلَّا!"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_points_each_word_of_a_phrase():
|
| 13 |
+
assert point_arabic("شو اسمك", "ʃ u | ʔ i s m a k") == "شُو اِسْمَكْ"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_public_phrase_has_a_separate_pointed_answer():
|
| 17 |
+
from app.phrases import public_phrases
|
| 18 |
+
|
| 19 |
+
answer = next(phrase for phrase in public_phrases() if phrase["he"] == "אני")
|
| 20 |
+
assert answer["ar"] == "انا"
|
| 21 |
+
assert answer["ar_pointed"] == "اَنَا"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_sawtak_keeps_pronunciation_enhancement_on_for_pointed_input(tmp_path):
|
| 25 |
+
from app.tts import SawtakTTS
|
| 26 |
+
|
| 27 |
+
request = {}
|
| 28 |
+
|
| 29 |
+
class Response:
|
| 30 |
+
headers = {"X-Sample-Rate": "24000"}
|
| 31 |
+
|
| 32 |
+
def __enter__(self):
|
| 33 |
+
return self
|
| 34 |
+
|
| 35 |
+
def __exit__(self, *_args):
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
def read(self):
|
| 39 |
+
return b"\0" * 5000
|
| 40 |
+
|
| 41 |
+
tts = SawtakTTS("key", cache_dir=tmp_path)
|
| 42 |
+
|
| 43 |
+
def fake_request(path, body=None):
|
| 44 |
+
request.update(path=path, body=body)
|
| 45 |
+
return Response()
|
| 46 |
+
|
| 47 |
+
tts._request = fake_request
|
| 48 |
+
tts.wav("اَكَلْ، يُاكُلْ", "voice")
|
| 49 |
+
assert request["path"] == "/audio/speech"
|
| 50 |
+
assert request["body"]["input"] == "اَكَلْ، يُاكُلْ"
|
| 51 |
+
assert request["body"]["enhance_pronunciation"] is True
|
| 52 |
+
assert "temperature" not in request["body"]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_raef_recording_is_cached_and_cache_can_be_cleared(tmp_path):
|
| 56 |
+
from app.tts import RAEF_VOICE_ID, SawtakTTS
|
| 57 |
+
|
| 58 |
+
generated, requests = 0, []
|
| 59 |
+
|
| 60 |
+
class Response:
|
| 61 |
+
headers = {"X-Sample-Rate": "24000"}
|
| 62 |
+
|
| 63 |
+
def __enter__(self):
|
| 64 |
+
return self
|
| 65 |
+
|
| 66 |
+
def __exit__(self, *_args):
|
| 67 |
+
pass
|
| 68 |
+
|
| 69 |
+
def read(self):
|
| 70 |
+
return b"\0" * 5000
|
| 71 |
+
|
| 72 |
+
tts = SawtakTTS("key", cache_dir=tmp_path)
|
| 73 |
+
|
| 74 |
+
def fake_request(_path, _body=None):
|
| 75 |
+
nonlocal generated
|
| 76 |
+
generated += 1
|
| 77 |
+
requests.append(_body)
|
| 78 |
+
return Response()
|
| 79 |
+
|
| 80 |
+
tts._request = fake_request
|
| 81 |
+
first, voice = tts.wav("مَرْحَبَا", "ignored-voice")
|
| 82 |
+
second, second_voice = tts.wav("مَرْحَبَا")
|
| 83 |
+
assert first == second
|
| 84 |
+
assert voice == second_voice == RAEF_VOICE_ID
|
| 85 |
+
assert generated == 1
|
| 86 |
+
assert all("temperature" not in body and body["enhance_pronunciation"] is True for body in requests)
|
| 87 |
+
|
| 88 |
+
(tmp_path / "keep.txt").write_text("not audio")
|
| 89 |
+
assert tts.clear_cache() == 1
|
| 90 |
+
assert (tmp_path / "keep.txt").exists()
|
| 91 |
+
assert not list(tmp_path.glob("*.wav"))
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_only_raef_is_offered_and_is_the_configured_default(monkeypatch):
|
| 95 |
+
from app.tts import RAEF_VOICE_ID, VOICES, load_tts
|
| 96 |
+
|
| 97 |
+
monkeypatch.setenv("SAWTAK_API_KEY", "key")
|
| 98 |
+
monkeypatch.setenv("SAWTAK_VOICE_ID", "an-old-different-voice")
|
| 99 |
+
tts = load_tts()
|
| 100 |
+
assert VOICES == {"רַאאִף": RAEF_VOICE_ID}
|
| 101 |
+
assert tts.voice_id == RAEF_VOICE_ID
|
| 102 |
+
|
| 103 |
+
|
tests/test_scoring.py
CHANGED
|
@@ -361,24 +361,24 @@ def test_tts_endpoint_serves_phrase_audio_and_503_without_key():
|
|
| 361 |
from app.main import create_app
|
| 362 |
|
| 363 |
class FakeTTS:
|
| 364 |
-
|
|
|
|
| 365 |
|
| 366 |
def wav(self, text, voice=""):
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
| 369 |
|
| 370 |
with TestClient(create_app(scorer_for(emissions(["w"])), FakeTTS())) as c:
|
| 371 |
r = c.get("/api/tts/0")
|
| 372 |
assert r.status_code == 200 and r.headers["content-type"] == "audio/wav" and r.content == b"RIFFfake"
|
| 373 |
-
assert r.headers["x-tts-provider"] == "sawtakarabi.ai" and r.headers["x-tts-voice-id"] ==
|
| 374 |
-
from urllib.parse import unquote
|
| 375 |
|
| 376 |
from app.tts import VOICES
|
| 377 |
voices = c.get("/api/phrases").json()["voices"]
|
| 378 |
assert [v["name"] for v in voices] == list(VOICES)
|
| 379 |
-
|
| 380 |
-
assert r.headers["x-tts-voice-id"] == voices[1]["id"] and unquote(r.headers["x-tts-voice-name"]) == voices[1]["name"]
|
| 381 |
-
assert c.get("/api/tts/0?voice=not-in-the-list").status_code == 400
|
| 382 |
assert c.get("/api/tts/9999").status_code == 404
|
| 383 |
health = c.get("/api/health").json()
|
| 384 |
assert health["tts"] is True and health["rules"]["pharyngeal_h"]["detectable"]
|
|
@@ -545,8 +545,10 @@ def test_references_use_one_generated_voice_and_any_cached_one():
|
|
| 545 |
refs = References(s, tts, ["first", "never-played", "played"])
|
| 546 |
assert len(refs.get(7, ALA)) == 2 and calls == ["first", "played"]
|
| 547 |
assert len(refs.get(7, ALA)) == 2 and len(calls) == 2 # kept in memory
|
|
|
|
|
|
|
| 548 |
tts.down = True
|
| 549 |
-
assert refs.get(8, ALA) == [] and refs.get(8, ALA) == [] and len(calls) ==
|
| 550 |
finally:
|
| 551 |
reference.decode_audio = orig
|
| 552 |
|
|
@@ -1354,7 +1356,8 @@ def test_sign_in_guards_the_practice_endpoints():
|
|
| 1354 |
"pos": N1, "ord": 2, "guessed": ["זַלַמֶה"]} # the course has the other words
|
| 1355 |
assert "voices" not in d["categories"][1] and d["categories"][1]["ord"] == 3
|
| 1356 |
line1, line2, line3 = d["phrases"][:3]
|
| 1357 |
-
assert line1 == {"id": 1_001_000, "cat": "t1", "he": "שלום", "tr": "מַרְחַבַּא", "ar": "مرحبا",
|
|
|
|
| 1358 |
assert (line2["id"], line2["ar"], line3["ar"]) == (1_001_001, "كيف حالك؟", "مرحبا يا زلمة") and len(d["phrases"]) == 6
|
| 1359 |
# a line is a phrase like the bank's: it has a prompt and an answer to play, and its recordings count (two lines
|
| 1360 |
# with the same Hebrew stay apart)
|
|
|
|
| 361 |
from app.main import create_app
|
| 362 |
|
| 363 |
class FakeTTS:
|
| 364 |
+
from app.tts import RAEF_VOICE_ID
|
| 365 |
+
voice_id, voice_name = RAEF_VOICE_ID, "רַאאִף"
|
| 366 |
|
| 367 |
def wav(self, text, voice=""):
|
| 368 |
+
from app.arabic_diacritics import point_arabic
|
| 369 |
+
assert text == point_arabic(*PHRASES[0]["variants"][0])
|
| 370 |
+
assert voice == self.voice_id
|
| 371 |
+
return b"RIFFfake", self.voice_id
|
| 372 |
|
| 373 |
with TestClient(create_app(scorer_for(emissions(["w"])), FakeTTS())) as c:
|
| 374 |
r = c.get("/api/tts/0")
|
| 375 |
assert r.status_code == 200 and r.headers["content-type"] == "audio/wav" and r.content == b"RIFFfake"
|
| 376 |
+
assert r.headers["x-tts-provider"] == "sawtakarabi.ai" and r.headers["x-tts-voice-id"] == FakeTTS.voice_id
|
|
|
|
| 377 |
|
| 378 |
from app.tts import VOICES
|
| 379 |
voices = c.get("/api/phrases").json()["voices"]
|
| 380 |
assert [v["name"] for v in voices] == list(VOICES)
|
| 381 |
+
assert voices == [{"id": FakeTTS.voice_id, "name": "רַאאִף"}]
|
|
|
|
|
|
|
| 382 |
assert c.get("/api/tts/9999").status_code == 404
|
| 383 |
health = c.get("/api/health").json()
|
| 384 |
assert health["tts"] is True and health["rules"]["pharyngeal_h"]["detectable"]
|
|
|
|
| 545 |
refs = References(s, tts, ["first", "never-played", "played"])
|
| 546 |
assert len(refs.get(7, ALA)) == 2 and calls == ["first", "played"]
|
| 547 |
assert len(refs.get(7, ALA)) == 2 and len(calls) == 2 # kept in memory
|
| 548 |
+
assert refs.invalidate(7, "first") == 1
|
| 549 |
+
assert len(refs.get(7, ALA)) == 2 and calls == ["first", "played", "first"]
|
| 550 |
tts.down = True
|
| 551 |
+
assert refs.get(8, ALA) == [] and refs.get(8, ALA) == [] and len(calls) == 5 # failures are not retried at once
|
| 552 |
finally:
|
| 553 |
reference.decode_audio = orig
|
| 554 |
|
|
|
|
| 1356 |
"pos": N1, "ord": 2, "guessed": ["זַלַמֶה"]} # the course has the other words
|
| 1357 |
assert "voices" not in d["categories"][1] and d["categories"][1]["ord"] == 3
|
| 1358 |
line1, line2, line3 = d["phrases"][:3]
|
| 1359 |
+
assert line1 == {"id": 1_001_000, "cat": "t1", "he": "שלום", "tr": "מַרְחַבַּא", "ar": "مرحبا",
|
| 1360 |
+
"ar_pointed": "مَرْحَبَا", "seq": 1}
|
| 1361 |
assert (line2["id"], line2["ar"], line3["ar"]) == (1_001_001, "كيف حالك؟", "مرحبا يا زلمة") and len(d["phrases"]) == 6
|
| 1362 |
# a line is a phrase like the bank's: it has a prompt and an answer to play, and its recordings count (two lines
|
| 1363 |
# with the same Hebrew stay apart)
|