3v324v23 commited on
Commit
ccffcbd
·
1 Parent(s): 4207cc3

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 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
- - `SAWTAK_VOICE_ID` is optional; by default the most liked ready Palestinian voice is used.
492
  - Without a key the endpoint answers 503 and the page falls back to the device's Arabic voice.
493
- - The same voices calibrate the scores (step 5 above). This generates the first voice of `tts.VOICES` once for every phrase that gets scored, also when the learner never presses "השמע"; the other voices are used only when already cached. 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`.
 
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, SAWTAK_VOICE_ID (model-answer playback, 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,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
- VOICE_NAMES = [re.sub(r"[\u0591-\u05c7]", "", name) for name in VOICES] # as a topic names its voices: without the points
 
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], "seq": p["seq"]}
 
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, voice: str = Query("", max_length=64)):
1230
- """The model answer (first variant) spoken by the TTS voice. 503 tells the page to use the device voice.
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
- if voice and voice not in names:
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, voice)
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, max-age=86400", "X-TTS-Provider": "sawtakarabi.ai",
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][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
- PALESTINIAN = ["ps", "ps-central", "ps-westbank", "ps-north", "ps-nablus", "ps-galilee", "ps-hebron", "ps-south", "ps-gaza"]
28
  TIMEOUT = 60
29
- # Voices the page offers in its drop-down (name shown -> Sawtak voice id). Only these can be requested.
30
- VOICES = {
31
- "אמיר": "5db8ef83-ac37-4a17-be23-e20446290367",
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 = "", cache_dir: Path | None = None):
42
- self.api_key, self.voice_id = api_key, voice_id
43
- self.voice_name = "" # known only when the voice was picked from the catalog
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 wav(self, text: str, voice: str = "") -> tuple[bytes, str]:
89
- """(WAV, voice id) for `text`, from the cache when this voice has said it before.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
- `voice` overrides the configured voice for this call (the page's voice drop-down).
92
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  with self._lock:
94
- voice = voice or self._voice()
95
  path = self._path(text, voice)
96
  if path.exists():
97
  return path.read_bytes(), voice
98
- # enhance_pronunciation: the phrase bank is unvowelled, so let the voice's dialect add the tashkeel
99
- with self._request("/audio/speech", {"model": MODEL, "voice": voice, "input": text,
100
- "response_format": "pcm", "enhance_pronunciation": True}) as res:
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, os.environ.get("SAWTAK_VOICE_ID", "").strip(), Path(cache) if cache else None)
 
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">אמיר ▾</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>, <b style="display:inline">ראאף</b> או <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>
 
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={}; // "phrase id|voice" -> {url} of the server-generated answer
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 ttsOff=false; // server has no TTS key: stop asking
2750
- // A story is a dialogue: its lines are said in turn by the topic's two voices, found by name (without the points) in
2751
- // the server's list, so the speakers sound apart. Anywhere else, and when a name is not in the list, the chosen voice.
2752
- function lineVoice(id){
2753
- const sel=$("voiceId"), c=!daily&&CATS.find(c=>c.id===cat)||{}, k=topic.indexOf(id);
2754
- const name=c.story&&c.voices&&k>=0&&c.voices[k%2], o=name&&[...sel.options].find(o=>o.text.replace(/[\u0591-\u05C7]/g,"")===name);
2755
- return o?o.value:sel.value;
 
 
 
 
 
 
2756
  }
 
2757
  async function answerAudio(id){ // {url} of the Sawtak answer, or null when the server voice is unavailable
2758
- const voice=lineVoice(id), key=id+"|"+voice;
2759
- if(!ttsOff && !ttsUrls[key]){
2760
  try{
2761
- const res=await api("/api/tts/"+id+(voice?"?voice="+encodeURIComponent(voice):""));
2762
  if(res.ok){
2763
- ttsUrls[key]={url:URL.createObjectURL(await res.blob())};
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[key]||null;
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){ if(cur.id===id){ answerPlayer(a.url).play(); } return; }
 
 
 
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=[...$("voiceId").options].filter(o=>o.value).map(o=>o.text.replace(/[֑-ׇ]/g,""));
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
- voice_id, voice_name = "v-123", "ليلى"
 
365
 
366
  def wav(self, text, voice=""):
367
- assert text == PHRASES[0]["variants"][0][0]
368
- return b"RIFFfake", voice or self.voice_id
 
 
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"] == "v-123"
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
- r = c.get("/api/tts/0?voice=" + voices[1]["id"])
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) == 4 # failures are not retried at once
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": "مرحبا", "seq": 1}
 
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)