3v324v23 commited on
Commit
4d3eef0
·
1 Parent(s): fa4aab8

Enhance demo video and TTS integration

Browse files

- Updated promo.html to improve layout and styling for new features, including enhanced word display and interaction elements.
- Modified promo.py to include new marks for user interactions and improved timing for screen recordings.
- Adjusted index.html to refine lesson continuation logic and ensure proper voice configuration for TTS.
- Enhanced test cases for Arabic diacritics and learner home browser to validate new story resumption logic and TTS playback configurations.
- Added tests for TTS endpoint to ensure proper audio serving and voice ID handling.
- Introduced a new OpenSpec configuration for the Grok library MCP.

.env.example CHANGED
@@ -4,6 +4,11 @@
4
  # Sawtak Arabi TTS key for the model-answer playback (https://sawtakarabi.ai/dashboard).
5
  # Without it the page falls back to the device's Arabic voice.
6
  SAWTAK_API_KEY=
 
 
 
 
 
7
 
8
  # Optional: the edge-tts voice of the spoken Hebrew prompts. Empty = he-IL-HilaNeural.
9
  HEBREW_VOICE=
 
4
  # Sawtak Arabi TTS key for the model-answer playback (https://sawtakarabi.ai/dashboard).
5
  # Without it the page falls back to the device's Arabic voice.
6
  SAWTAK_API_KEY=
7
+ # Optional: the Sawtak voice used for normal model-answer playback. Empty = Raef.
8
+ # Pronunciation-score baselines keep their fixed reference voices and do not use this setting.
9
+ SAWTAK_VOICE_ID=
10
+ # its display name (default אמיר); playback only, the score baseline stays Raef + Fadwa
11
+ SAWTAK_VOICE_NAME=
12
 
13
  # Optional: the edge-tts voice of the spoken Hebrew prompts. Empty = he-IL-HilaNeural.
14
  HEBREW_VOICE=
README.md CHANGED
@@ -786,9 +786,9 @@ Tune them on a handful of labelled learner recordings. A useful target is that n
786
 
787
  - Local: `cp .env.example .env` and set `SAWTAK_API_KEY` (`.env` is git-ignored and excluded from the Docker image).
788
  - Hugging Face: add `SAWTAK_API_KEY` as a Space **secret** (Settings → Variables and secrets). It is injected as an environment variable at runtime.
789
- - Playback, candidate generation and score calibration all use the Palestinian voice רַאאִף.
790
  - Without a key the endpoint answers 503 and the page falls back to the device's Arabic voice.
791
- - 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`.
792
 
793
  ## Cost estimate (API keys)
794
 
 
786
 
787
  - Local: `cp .env.example .env` and set `SAWTAK_API_KEY` (`.env` is git-ignored and excluded from the Docker image).
788
  - Hugging Face: add `SAWTAK_API_KEY` as a Space **secret** (Settings → Variables and secrets). It is injected as an environment variable at runtime.
789
+ - Model-answer playback uses `SAWTAK_VOICE_ID`, or the Palestinian voice רַאאִף when it is unset.
790
  - Without a key the endpoint answers 503 and the page falls back to the device's Arabic voice.
791
+ - Score calibration keeps its fixed native baseline voices; it does not use `SAWTAK_VOICE_ID`. A baseline is prepared 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`.
792
 
793
  ## Cost estimate (API keys)
794
 
app/main.py CHANGED
@@ -62,7 +62,8 @@ from .archi import archify
62
  from .scorer import SAMPLE_RATE, Scorer, decode_audio, decode_audio_with_timing, frame_energy, load_scorer, tokenize
63
  from .speech_evaluation import SpeechEvaluationService
64
  from .texts import read_line, text_phrases
65
- from .tts import BASELINE_VOICES, LINE_VOICES, RAEF_VOICE_ID, VOICES, SawtakTTS, TTSUnavailable, load_tts, voice_name
 
66
  from .tutor import (BY_ID, DAILY_LIMIT, MAX_TEXT, MAX_TURNS, SCENARIOS, Tutor, TutorLimit,
67
  DEFAULT_MODEL as DEFAULT_TUTOR_MODEL, TutorUnavailable, load_tutor, tutor_client)
68
  from .verb_game import VerbExerciseGenerator
@@ -537,7 +538,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
537
  app.state.tts_warm_lock = threading.Lock()
538
  app.state.tts_warm_stop = threading.Event() # set by POST /api/tts-cache/warm/stop
539
  app.state.tutor = tutor or load_tutor(app.state.keys.get("ANTHROPIC_API_KEY"), app.state.keys.get("TUTOR_MODEL"))
540
- # the playback voices double as the native baseline of the scores (see reference.py)
541
  app.state.refs = References(app.state.scorer, app.state.tts, list(BASELINE_VOICES.values())) if app.state.tts else None
542
  if app.state.auth and hasattr(app.state.auth, "calibrations"):
543
  try:
@@ -2590,7 +2591,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2590
  @app.get("/api/phrases")
2591
  def phrases():
2592
  return {"levels": LEVELS, "lessons": LESSONS, "categories": CATEGORIES, "games": GAMES, "phrases": public_phrases(),
2593
- "voices": [{"id": i, "name": n} for n, i in VOICES.items()]}
2594
 
2595
  def verb_lessons(user: dict | None) -> list[str]:
2596
  """Lessons available to this learner, using the existing class rules."""
@@ -2766,8 +2767,9 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2766
 
2767
  @app.get("/api/tts/{phrase_id}", dependencies=[Depends(require("practice.use"))])
2768
  def tts_phrase(phrase_id: int):
2769
- """The model answer spoken by Raef, or by a dialogue line's own voice. 503 tells the page to use the device voice."""
2770
- names = {i: n for n, i in {**VOICES, **LINE_VOICES}.items()}
 
2771
  phrase = phrase_of(phrase_id)
2772
  text = tts_arabic(*phrase["variants"][0])
2773
  if app.state.tts is None:
@@ -2790,7 +2792,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2790
  try:
2791
  with app.state.tts_warm_lock:
2792
  warm = dict(app.state.tts_warm)
2793
- return {"available": True, **app.state.tts.cache_stats(), "warm": warm}
 
2794
  except TTSUnavailable as e:
2795
  raise HTTPException(503, str(e))
2796
 
 
62
  from .scorer import SAMPLE_RATE, Scorer, decode_audio, decode_audio_with_timing, frame_energy, load_scorer, tokenize
63
  from .speech_evaluation import SpeechEvaluationService
64
  from .texts import read_line, text_phrases
65
+ from .tts import (BASELINE_VOICES, LINE_VOICES, RAEF_VOICE_ID, VOICES, SawtakTTS, TTSUnavailable, load_tts,
66
+ playback_voices, voice_name)
67
  from .tutor import (BY_ID, DAILY_LIMIT, MAX_TEXT, MAX_TURNS, SCENARIOS, Tutor, TutorLimit,
68
  DEFAULT_MODEL as DEFAULT_TUTOR_MODEL, TutorUnavailable, load_tutor, tutor_client)
69
  from .verb_game import VerbExerciseGenerator
 
538
  app.state.tts_warm_lock = threading.Lock()
539
  app.state.tts_warm_stop = threading.Event() # set by POST /api/tts-cache/warm/stop
540
  app.state.tutor = tutor or load_tutor(app.state.keys.get("ANTHROPIC_API_KEY"), app.state.keys.get("TUTOR_MODEL"))
541
+ # Baselines deliberately stay fixed; SAWTAK_VOICE_ID changes playback only (see tts.load_tts).
542
  app.state.refs = References(app.state.scorer, app.state.tts, list(BASELINE_VOICES.values())) if app.state.tts else None
543
  if app.state.auth and hasattr(app.state.auth, "calibrations"):
544
  try:
 
2591
  @app.get("/api/phrases")
2592
  def phrases():
2593
  return {"levels": LEVELS, "lessons": LESSONS, "categories": CATEGORIES, "games": GAMES, "phrases": public_phrases(),
2594
+ "voices": [{"id": i, "name": n} for n, i in playback_voices().items()]}
2595
 
2596
  def verb_lessons(user: dict | None) -> list[str]:
2597
  """Lessons available to this learner, using the existing class rules."""
 
2767
 
2768
  @app.get("/api/tts/{phrase_id}", dependencies=[Depends(require("practice.use"))])
2769
  def tts_phrase(phrase_id: int):
2770
+ """The model answer in the playback voice (SAWTAK_VOICE_ID, else Raef), or a dialogue line's own voice.
2771
+ 503 tells the page to use the device voice."""
2772
+ names = {i: n for n, i in {**playback_voices(), **LINE_VOICES}.items()}
2773
  phrase = phrase_of(phrase_id)
2774
  text = tts_arabic(*phrase["variants"][0])
2775
  if app.state.tts is None:
 
2792
  try:
2793
  with app.state.tts_warm_lock:
2794
  warm = dict(app.state.tts_warm)
2795
+ return {"available": True, "voice_id": getattr(app.state.tts, "voice_id", RAEF_VOICE_ID),
2796
+ **app.state.tts.cache_stats(), "warm": warm}
2797
  except TTSUnavailable as e:
2798
  raise HTTPException(503, str(e))
2799
 
app/tts.py CHANGED
@@ -4,6 +4,7 @@ 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
  TTS_CACHE_DIR optional; default ~/.cache/ihki-tts
8
  TTS_BUCKET optional Hugging Face Storage Bucket id (e.g. "user/ihki-storage"); the cache-warm sweep
9
  uploads every cached WAV to it through the Bucket API (needs HF_TOKEN with write access)
@@ -40,7 +41,7 @@ MAX_AUDIO_SECONDS = 60 # the longest phrase is ~240 characters; Sawtak's broken
40
  # for this server, and at most 3 are in flight, so student playback and other clients keep some room. 429s are retried.
41
  MIN_REQUEST_INTERVAL = 60 / 45
42
  MAX_CONCURRENT = 3
43
- # The single Sawtak voice used throughout the application.
44
  RAEF_VOICE_ID = "273d81b2-991b-5b76-8525-eeacc6a3d759"
45
  VOICES = {"רַאאִף": RAEF_VOICE_ID}
46
  # A female voice, only for the lines a dialogue gives her (workbook.LINE_VOICES); never offered on the page.
@@ -51,9 +52,19 @@ FADWA_VOICE_ID = "39a7e0e0-087e-52b3-afff-5203e430fc39" # فدوى: Palestinia
51
  BASELINE_VOICES = {"ראאף": RAEF_VOICE_ID, "פדוא": FADWA_VOICE_ID}
52
 
53
 
 
 
 
 
 
 
 
 
 
 
54
  def voice_name(voice_id: str) -> str:
55
  """A voice id's display name, without vowel points (ראאף, חנאן); the id itself when it is not one of ours."""
56
- names = {v: k for k, v in {**VOICES, **LINE_VOICES, **BASELINE_VOICES}.items()}
57
  return names.get(voice_id, voice_id).translate({c: None for c in range(0x591, 0x5c8)})
58
 
59
  class TTSUnavailable(Exception):
@@ -107,6 +118,8 @@ class BucketMirror:
107
  def __init__(self, bucket_id: str, prefix: str = "ihki-tts", token: str | None = None):
108
  self.bucket_id, self.prefix, self.token = bucket_id, prefix.strip("/"), token
109
  self.activity: ActivityLog | None = None # set when attached to a SawtakTTS
 
 
110
 
111
  def remote_path(self, rel: str) -> str:
112
  return f"{self.prefix}/{rel}" if self.prefix else rel
@@ -122,6 +135,22 @@ class BucketMirror:
122
  out[item.path] = size
123
  return out
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  def upload(self, files: list[tuple[Path | bytes, str]], source: str = "") -> None:
126
  """Upload (local path or bytes, bucket path) pairs in one batch."""
127
  from huggingface_hub import batch_bucket_files
@@ -141,8 +170,8 @@ class BucketMirror:
141
 
142
  class SawtakTTS:
143
  def __init__(self, api_key: str, voice_id: str = RAEF_VOICE_ID, cache_dir: Path | None = None):
144
- self.api_key, self.voice_id = api_key, RAEF_VOICE_ID
145
- self.voice_name = "רַאאִף"
146
  self.cache_dir = cache_dir or Path.home() / ".cache" / "ihki-tts"
147
  # Separate locks, so nothing slow holds up a generation: walking a bucket-mounted cache folder for the
148
  # panel's file count takes tens of seconds, and it once stalled every sweep worker behind it.
@@ -359,7 +388,12 @@ class SawtakTTS:
359
  with self._counts:
360
  hits, misses = self._cache_hits, self._cache_misses
361
  requests = hits + misses
362
- return {"files": disk[1] if disk else None, "bytes": disk[2] if disk else None, "hits": hits,
 
 
 
 
 
363
  "misses": misses, "hit_rate": round(hits / requests, 4) if requests else None}
364
 
365
  def _count_files(self) -> None:
@@ -380,7 +414,7 @@ class SawtakTTS:
380
  with self._key_lock(self.cache_key(text, voice)):
381
  found = self.cache_file(text, voice) if self.staging_dir else None
382
  if found is not None: # with a mirror the cache folder may be a slow mount: no migration there
383
- self._count(hit=True)
384
  return found.read_bytes(), voice
385
  path = self._path(text, voice)
386
  legacy = self._legacy_path(path)
@@ -392,9 +426,9 @@ class SawtakTTS:
392
  log.warning("tts cache migration failed: %s: %s", type(e).__name__, e)
393
  path = legacy
394
  if path.exists():
395
- self._count(hit=True)
396
  return path.read_bytes(), voice
397
- self._count(hit=False)
398
  # The caller supplies the phrase's Palestinian vowel marks. Sawtak preserves existing
399
  # marks; enhancement fills remaining pronunciation cues using the selected voice's dialect.
400
  data = self._generate(text, voice, source)
@@ -430,8 +464,8 @@ class SawtakTTS:
430
  threading.Thread(target=go, name="ihki-tts-upload", daemon=True).start()
431
 
432
  def wav(self, text: str, voice: str = "") -> tuple[bytes, str]:
433
- """(WAV, voice id) in Raef's voice, or a dialogue line's configured voice."""
434
- voice = voice if voice in LINE_VOICES.values() else RAEF_VOICE_ID
435
  return self._wav(text, voice, upload=True, source="playback")
436
 
437
  def wav_for_voice(self, text: str, voice: str, source: str = "sweep", upload: bool = False) -> tuple[bytes, str]:
@@ -447,7 +481,8 @@ def load_tts(key: str | None = None) -> SawtakTTS | None:
447
  log.info("SAWTAK_API_KEY not set: playback uses the device voice")
448
  return None
449
  cache = os.environ.get("TTS_CACHE_DIR", "").strip()
450
- tts = SawtakTTS(key, RAEF_VOICE_ID, Path(cache) if cache else None)
 
451
  bucket = os.environ.get("TTS_BUCKET", "").strip()
452
  if bucket:
453
  tts.mirror = BucketMirror(bucket, os.environ.get("TTS_BUCKET_PREFIX", "ihki-tts"),
 
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 voice used to read sentences; default is Raef
8
  TTS_CACHE_DIR optional; default ~/.cache/ihki-tts
9
  TTS_BUCKET optional Hugging Face Storage Bucket id (e.g. "user/ihki-storage"); the cache-warm sweep
10
  uploads every cached WAV to it through the Bucket API (needs HF_TOKEN with write access)
 
41
  # for this server, and at most 3 are in flight, so student playback and other clients keep some room. 429s are retried.
42
  MIN_REQUEST_INTERVAL = 60 / 45
43
  MAX_CONCURRENT = 3
44
+ # The default Sawtak voice used throughout the application.
45
  RAEF_VOICE_ID = "273d81b2-991b-5b76-8525-eeacc6a3d759"
46
  VOICES = {"רַאאִף": RAEF_VOICE_ID}
47
  # A female voice, only for the lines a dialogue gives her (workbook.LINE_VOICES); never offered on the page.
 
52
  BASELINE_VOICES = {"ראאף": RAEF_VOICE_ID, "פדוא": FADWA_VOICE_ID}
53
 
54
 
55
+ def playback_voices() -> dict[str, str]:
56
+ """The playback voices by name: VOICES, plus the one SAWTAK_VOICE_ID sets, named by SAWTAK_VOICE_NAME (אמיר).
57
+ Read when called, after main.py has loaded .env. Playback only: the baseline stays BASELINE_VOICES."""
58
+ out = dict(VOICES)
59
+ voice = os.environ.get("SAWTAK_VOICE_ID", "").strip()
60
+ if voice and voice not in out.values():
61
+ out[os.environ.get("SAWTAK_VOICE_NAME", "").strip() or "אַמִיר"] = voice
62
+ return out
63
+
64
+
65
  def voice_name(voice_id: str) -> str:
66
  """A voice id's display name, without vowel points (ראאף, חנאן); the id itself when it is not one of ours."""
67
+ names = {v: k for k, v in {**playback_voices(), **LINE_VOICES, **BASELINE_VOICES}.items()}
68
  return names.get(voice_id, voice_id).translate({c: None for c in range(0x591, 0x5c8)})
69
 
70
  class TTSUnavailable(Exception):
 
118
  def __init__(self, bucket_id: str, prefix: str = "ihki-tts", token: str | None = None):
119
  self.bucket_id, self.prefix, self.token = bucket_id, prefix.strip("/"), token
120
  self.activity: ActivityLog | None = None # set when attached to a SawtakTTS
121
+ self._stats_lock = threading.Lock()
122
+ self._stats: tuple[float, int, int] | None = None
123
 
124
  def remote_path(self, rel: str) -> str:
125
  return f"{self.prefix}/{rel}" if self.prefix else rel
 
135
  out[item.path] = size
136
  return out
137
 
138
+ def cache_stats(self, max_age: float = 60) -> tuple[int, int] | None:
139
+ """Cached WAV count/size in the shared bucket; refresh without holding up the monitoring request."""
140
+ stats = self._stats
141
+ if (stats is None or time.monotonic() - stats[0] > max_age) and self._stats_lock.acquire(blocking=False):
142
+ def scan() -> None:
143
+ try:
144
+ sizes = self.remote_sizes()
145
+ wavs = [size for path, size in sizes.items() if path.endswith(".wav")]
146
+ self._stats = (time.monotonic(), len(wavs), sum(wavs))
147
+ except Exception as e:
148
+ log.warning("could not inspect TTS bucket: %s", e)
149
+ finally:
150
+ self._stats_lock.release()
151
+ threading.Thread(target=scan, name="ihki-tts-bucket-count", daemon=True).start()
152
+ return (stats[1], stats[2]) if stats else None
153
+
154
  def upload(self, files: list[tuple[Path | bytes, str]], source: str = "") -> None:
155
  """Upload (local path or bytes, bucket path) pairs in one batch."""
156
  from huggingface_hub import batch_bucket_files
 
170
 
171
  class SawtakTTS:
172
  def __init__(self, api_key: str, voice_id: str = RAEF_VOICE_ID, cache_dir: Path | None = None):
173
+ self.api_key, self.voice_id = api_key, voice_id.strip() or RAEF_VOICE_ID
174
+ self.voice_name = voice_name(self.voice_id)
175
  self.cache_dir = cache_dir or Path.home() / ".cache" / "ihki-tts"
176
  # Separate locks, so nothing slow holds up a generation: walking a bucket-mounted cache folder for the
177
  # panel's file count takes tens of seconds, and it once stalled every sweep worker behind it.
 
388
  with self._counts:
389
  hits, misses = self._cache_hits, self._cache_misses
390
  requests = hits + misses
391
+ shared = self.mirror.cache_stats() if self.mirror is not None and hasattr(self.mirror, "cache_stats") else None
392
+ if self.mirror is not None and hasattr(self.mirror, "cache_stats"):
393
+ files, size = shared if shared is not None else (None, None)
394
+ else:
395
+ files, size = (disk[1], disk[2]) if disk else (None, None)
396
+ return {"files": files, "bytes": size, "hits": hits,
397
  "misses": misses, "hit_rate": round(hits / requests, 4) if requests else None}
398
 
399
  def _count_files(self) -> None:
 
414
  with self._key_lock(self.cache_key(text, voice)):
415
  found = self.cache_file(text, voice) if self.staging_dir else None
416
  if found is not None: # with a mirror the cache folder may be a slow mount: no migration there
417
+ if source == "playback": self._count(hit=True)
418
  return found.read_bytes(), voice
419
  path = self._path(text, voice)
420
  legacy = self._legacy_path(path)
 
426
  log.warning("tts cache migration failed: %s: %s", type(e).__name__, e)
427
  path = legacy
428
  if path.exists():
429
+ if source == "playback": self._count(hit=True)
430
  return path.read_bytes(), voice
431
+ if source == "playback": self._count(hit=False)
432
  # The caller supplies the phrase's Palestinian vowel marks. Sawtak preserves existing
433
  # marks; enhancement fills remaining pronunciation cues using the selected voice's dialect.
434
  data = self._generate(text, voice, source)
 
464
  threading.Thread(target=go, name="ihki-tts-upload", daemon=True).start()
465
 
466
  def wav(self, text: str, voice: str = "") -> tuple[bytes, str]:
467
+ """(WAV, voice id) in the configured voice, or a dialogue line's own voice."""
468
+ voice = voice if voice in LINE_VOICES.values() else self.voice_id
469
  return self._wav(text, voice, upload=True, source="playback")
470
 
471
  def wav_for_voice(self, text: str, voice: str, source: str = "sweep", upload: bool = False) -> tuple[bytes, str]:
 
481
  log.info("SAWTAK_API_KEY not set: playback uses the device voice")
482
  return None
483
  cache = os.environ.get("TTS_CACHE_DIR", "").strip()
484
+ voice = os.environ.get("SAWTAK_VOICE_ID", RAEF_VOICE_ID).strip() or RAEF_VOICE_ID
485
+ tts = SawtakTTS(key, voice, Path(cache) if cache else None)
486
  bucket = os.environ.get("TTS_BUCKET", "").strip()
487
  if bucket:
488
  tts.mirror = BucketMirror(bucket, os.environ.get("TTS_BUCKET_PREFIX", "ihki-tts"),
openspec/changes/add-grok-library-mcp/.openspec.yaml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ schema: spec-driven
2
+ created: 2026-10-26
scripts/demo_video/README.md CHANGED
@@ -24,15 +24,18 @@ D=scripts/demo_video; .venv/bin/python $D/record.py --takes $D/takes --out $D/ih
24
  app says "מצוין" and shows no letters to tap; if a scoring change moves it, pick another take in make_takes.py.
25
  - The app listens by itself after "next"; the recorder plays the take into the fake mic either way.
26
 
27
- ## The short promo (76 s, a 3D iPhone)
28
 
29
  The opening logo, with "חדש! האפליקציה של אהלא וסהלא" rising under it; the booklet (its cover face on, then lying
30
- open, pages flipping to lesson 4's new verbs, the row "דיבר · חכא · יחכי" circled); the focused home ("מה עושים
31
- עכשיו?", its two actions numbered); "דיבר (עבר, עתיד)" said with a Hebrew speaker's mistake (ح said as a "dirty" ח', خ),
32
- the red consonant and its tip, the retry at 100; collecting beads; driving mode (the phone flies into a car's mount,
33
- the conversation in bubbles, a misbaha on the mirror whose next bead turns gold on the verdict); a grid of the other
34
- ways to practice (lesson 4's new words, pairs, story, and "הפועל זכרון"), each coming forward for a short example;
35
- the summary beside "הערבית שכבר שלכם"; the animated logo. Silent.
 
 
 
36
 
37
  The focused home appears only to a signed-in student of a class; with sign-in off, promo.py draws it with the app's
38
  own component (`LearnerHome.renderHome`) for class א1 on lesson 4, with the two actions such a student would get.
 
24
  app says "מצוין" and shows no letters to tap; if a scoring change moves it, pick another take in make_takes.py.
25
  - The app listens by itself after "next"; the recorder plays the take into the fake mic either way.
26
 
27
+ ## The short promo (88 s, a 3D iPhone)
28
 
29
  The opening logo, with "חדש! האפליקציה של אהלא וסהלא" rising under it; the booklet (its cover face on, then lying
30
+ open, pages flipping to lesson 4's new verbs, the camera going into the line "דיבר · חכא · יחכי", whose three words then lift off the page, float over the home and
31
+ land in the practice: דיבר on the prompt when the practice opens, חכא and יחכי on the answer once the app reveals it); the focused home
32
+ ("מה עושים עכשיו?", its two actions numbered); "דיבר (עבר, עתיד)" said with a Hebrew speaker's mistake: chat bubbles
33
+ show what the student said (the ח' marked), a card compares ח' (חיכוך בחך) with ح (מהלוע), the app says it right, the
34
+ second try comes back at 100; collecting beads; driving mode (the phone in a car's mount, the conversation in bubbles,
35
+ a misbaha whose next bead turns gold); a grid of the other ways to practice (lesson 4's new words, pairs, story, and
36
+ "הפועל זכרון"), each coming forward for a short example; the summary, "החוברת מלמדת את השפה. האפליקציה עוזרת לשלוף,
37
+ לדבר, לתקן ולזכור", beside "הערבית שכבר שלכם"; the animated logo. Silent. The app's screens play 15% slower than
38
+ recorded (`SLOW` in promo.html).
39
 
40
  The focused home appears only to a signed-in student of a class; with sign-in off, promo.py draws it with the app's
41
  own component (`LearnerHome.renderHome`) for class א1 on lesson 4, with the two actions such a student would get.
scripts/demo_video/promo.html CHANGED
@@ -29,6 +29,16 @@ body{font-family:"Assistant",system-ui,sans-serif;color:var(--navy)}
29
  .cap h1 em{font-style:normal;color:var(--red)}
30
  .cap p{margin:22px 0 0;font:600 38px/1.35 "Assistant",sans-serif;color:var(--muted)}
31
  .cap p b{color:var(--teal);font-weight:800}
 
 
 
 
 
 
 
 
 
 
32
  /* the phone */
33
  #stage{position:absolute;inset:0;perspective:2600px;perspective-origin:50% 45%}
34
  #phone{position:absolute;left:220px;top:504px;width:641px;height:1353px;transform-style:preserve-3d;will-change:transform}
@@ -44,6 +54,8 @@ body{font-family:"Assistant",system-ui,sans-serif;color:var(--navy)}
44
  #shadow{position:absolute;left:250px;top:1790px;width:580px;height:90px;border-radius:50%;
45
  background:radial-gradient(closest-side,rgba(31,33,86,.38),rgba(31,33,86,0));filter:blur(6px)}
46
  /* a ring around the red letter, drawn outside the phone's screen so it reads at a glance */
 
 
47
  #ring{position:absolute;border:7px solid var(--red);border-radius:22px;box-shadow:0 0 0 10px rgba(200,52,74,.18);opacity:0}
48
  /* the opening and closing */
49
  /* the announcement, and the booklet the app follows */
@@ -62,7 +74,8 @@ body{font-family:"Assistant",system-ui,sans-serif;color:var(--navy)}
62
  #bookShadow{position:absolute;width:1150px;height:200px;border-radius:50%;background:radial-gradient(closest-side,rgba(31,33,86,.30),rgba(31,33,86,0));filter:blur(8px)}
63
  #bookRing{position:absolute;border:7px solid var(--red);border-radius:18px;box-shadow:0 0 0 10px rgba(200,52,74,.18);opacity:0}
64
  /* the summary: what the app is, beside its "הערבית שכבר שלכם" */
65
- #pills{position:absolute;left:60px;right:60px;top:300px;display:grid;grid-template-columns:1fr 1fr;gap:20px;z-index:6;opacity:0;direction:rtl}
 
66
  #pills div{background:#fff;border-radius:28px;padding:22px 20px;text-align:center;font:700 36px/1.2 "Assistant",sans-serif;color:var(--navy);
67
  box-shadow:0 8px 24px rgba(31,33,86,.10),inset 0 0 0 3px rgba(31,33,86,.06)}
68
  /* the grid of other ways to practice: four small phones, each zooming in for its example */
@@ -145,14 +158,17 @@ body{font-family:"Assistant",system-ui,sans-serif;color:var(--navy)}
145
  </div>
146
  <div id="band"></div>
147
  <div class="cap" id="cb"><h1>בנויה בדיוק <em style="color:var(--teal)">לפי החוברת</em> 📖</h1><p>כל שיעור בחוברת הוא נושא באפליקציה,<br>עם אותם פעלים ואותם משפטים</p></div>
148
- <div class="cap" id="c8"><h1>כל הערבית שלכם, <em style="color:var(--teal)">במקום אחד</em></h1></div>
149
- <div id="pills"><div>🎙️ שומעת כל עיצור</div><div>📖 לפי החוברת</div><div>🚗 מצב נסיעה</div><div>📿 חרוזים ומשחקים</div></div>
150
  <div class="cap" id="ch"><h1>יודעים <em style="color:var(--teal)">מה לתרגל עכשיו</em></h1><p><span class="nb">1</span> ממשיכים בשיעור של הכיתה<br><span class="nb">2</span> או תרגול קצר, כמה דקות</p></div>
151
  <div class="co" id="co1"><span class="n">1</span></div>
152
  <div class="co" id="co2"><span class="n">2</span></div>
153
  <div class="cap" id="c1"><h1>אומרים בקול</h1><p>רואים פועל בעברית,<br>ואומרים אותו <b>בערבית מדוברת</b></p></div>
154
  <div class="cap" id="c2"><h1>האפליקציה מקשיבה<br>ל<em>כל עיצור</em></h1><p>ומסמנת באדום את ��ה<br>שלא נשמע נכון</p></div>
155
- <div class="cap" id="c3"><h1>ח׳ במקום <em>ח</em>?</h1><p>טיפ שנכתב <b>לדוברי עברית</b>:<br>מה נשמע, ואיך מתקנים</p></div>
 
 
 
156
  <div class="cap" id="c4"><h1>מנסים שוב — <em style="color:var(--teal)">100!</em></h1><p>ח נקייה, <b>בדיוק כמו שצריך</b></p></div>
157
  <div class="cap" id="c5"><h1>כל משפט — <em style="color:var(--orange)">חרוז</em> 📿</h1><p>מ־75 ומעלה הוא מזהיב ★<br>ואוספים אותם לאורך כל הדרך</p></div>
158
  <div class="cap" id="c7"><h1>מצב נסיעה 🚗</h1><p>מתרגלים <b>בלי ידיים</b>: האפליקציה מקריאה,<br>מקשיבה ואומרת את הציון בקול</p></div>
@@ -172,6 +188,7 @@ body{font-family:"Assistant",system-ui,sans-serif;color:var(--navy)}
172
  </div>
173
  </div>
174
  <div id="ring"></div>
 
175
  <div id="grid">
176
  <div class="tile" id="tw"><div class="ph"><div class="scr"><img alt=""><div class="isl"></div></div></div><div class="lab">📖 מילים חדשות</div></div>
177
  <div class="tile" id="tp"><div class="ph"><div class="scr"><img alt=""><div class="isl"></div></div></div><div class="lab">🧩 זוגות מילים</div></div>
@@ -183,6 +200,10 @@ body{font-family:"Assistant",system-ui,sans-serif;color:var(--navy)}
183
  <circle cx="170" cy="1930" r="440" fill="none" stroke="#2C2F45" stroke-width="10" stroke-dasharray="40 26" opacity=".7"/>
184
  <path d="M170 1930 L560 1760" stroke="#14151F" stroke-width="70" stroke-linecap="round"/>
185
  </g></svg>
 
 
 
 
186
  <div class="bub app" id="b1">🔊 דיבר — עבר ועתיד</div>
187
  <div class="bub me" id="b2">🎙️ <span class="tr">חַכַּא, יִחְכִּי</span></div>
188
  <div class="bub ok" id="b3">🔊 מצוין! · 100 ✓</div>
@@ -202,7 +223,7 @@ body{font-family:"Assistant",system-ui,sans-serif;color:var(--navy)}
202
  const $=id=>document.getElementById(id);
203
  // the opening logo, the announcement and the booklet come before the screen; the animated logo closes
204
  // the logo stays up while the description rises under it
205
- const OPEN_END=4.2, ANN=[1.0,4.2], BOOK0=4.0, BOOK_LEN=7.0, INTRO=BOOK0+BOOK_LEN, CHIPS=0.5, LOGO=6.8, LOGO_SPEED=1.2;
206
  const SLOW=1.15; // the app's screens play 15% slower than recorded, to be read
207
  const PW=520, PH=716; // a booklet page on the frame
208
  const LEAVES=[["cover.jpg",null],["p53.jpg","p54.jpg"],["p55.jpg","p56.jpg"],["p57.jpg","p58.jpg"],["p59.jpg","p60.jpg"]];
@@ -271,7 +292,8 @@ async function setup(d){
271
  [S(M.score.t)+.9,focus(lu,lv,1.5,540,1150,{ry:-4,rx:2})],
272
  [S(M.tap.t)+.3,focus(lu,lv,1.5,540,1150,{ry:-3,rx:2})],
273
  [S(M.tap.t)+1.2,focus((lu+tu)/2,(lv+tv)/2,1.22,540,1250,{ry:3,rx:2})],
274
- [S(M.retry.t)-.2,focus((lu+tu)/2,(lv+tv)/2,1.22,540,1250,{ry:4,rx:2})],
 
275
  [S(M.retry.t)+.7,{...rest,ry:10,rx:3}],
276
  [S(M.great.t),{...rest,ry:8,rx:3}],
277
  [S(M.great.t)+.8,focus(gu,gv,1.3,540,1250,{ry:4,rx:2})],
@@ -297,12 +319,38 @@ function stateAt(t){
297
  function frameAt(s){ // the last screen frame at or before s
298
  let lo=0, hi=F.length-1; while(lo<hi){ const m=(lo+hi+1)>>1; if(F[m].t<=s) lo=m; else hi=m-1; } return F[lo].file;
299
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  function drawBook(t){
301
  const b=t-BOOK0, on=t>BOOK0-.1&&t<INTRO+.3;
302
  $("bookStage").style.visibility=on?"visible":"hidden"; $("bookRing").style.opacity=0; if(!on) return;
303
  // the booklet: face on and closed (its spine on the right), then lying down, open, flat for a close look at the verb
304
  const lay=ease((b-.9)/1.0), flat=ease((b-4.3)/.9), k0=lerp(lerp(1.16,1.22,ease(b/.9)),.92,lay);
305
- const k=lerp(k0,1.55,flat), rx=lerp(lerp(0,54,lay),4,flat), rz=lerp(lerp(0,-7,lay),0,flat);
 
306
  const fu=ROW.u+ROW.w/2, fv=ROW.v+ROW.h/2; // the row comes to the frame's middle
307
  const sx=lerp(lerp(540+PW*k0/2,540,lay),540-k*fu*PW,flat), sy=lerp(lerp(1040,1110,lay),1060-k*(fv-.5)*PH,flat);
308
  $("book").style.transform=`translate(${sx}px,${sy}px) rotateX(${rx}deg) rotateZ(${rz}deg) scale(${k})`;
@@ -313,7 +361,7 @@ function drawBook(t){
313
  l.style.transform=`rotateY(${ang}deg) translateZ(${l.dataset.z*1.2}px)`;
314
  l.querySelectorAll(".shade").forEach(s=>s.style.opacity=Math.sin(Math.PI*x)*.9); });
315
  // the ring on the row, once the booklet lies flat
316
- const r=win(b,5.1,BOOK_LEN-.1,.25);
317
  if(r>0){ const x=sx+k*ROW.u*PW, y=sy+k*(ROW.v-.5)*PH, pad=12;
318
  Object.assign($("bookRing").style,{left:x-pad+"px",top:y-pad+"px",width:k*ROW.w*PW+2*pad+"px",height:k*ROW.h*PH+2*pad+"px",
319
  opacity:Math.min(r,out),transform:`scale(${1+.05*Math.sin(b*7)})`}); }
@@ -338,7 +386,7 @@ async function seek(t){
338
  $("announce").style.opacity=an; $("announce").style.visibility=an>0?"visible":"hidden";
339
  $("announce").style.transform=`translateY(${(1-rise)*820}px)`;
340
  drawBook(t);
341
- const caps=[["cb",BOOK0+.9,INTRO-.5],["ch",INTRO-.5,S(M.homeTap.t)+.3],["c1",S(M.start.t)-.1,S(M.score.t)+.2],["c8",S(M.shelf.t)-.1,S(END)+.4],["c2",S(M.score.t)+.2,S(M.tap.t)+.6],["c3",S(M.tap.t)+.6,S(M.retry.t)+.4],
342
  ["c4",S(M.retry.t)+.4,S(M.great.t)+1.5],["c5",S(M.great.t)+1.5,S(M.drive.t)-.2],
343
  ["c7",S(M.drive.t)-.2,S(M.driveEnd.t)+.2],
344
  ["cg",S(M.w0.t)-.1,S(M.w0.t)+1.6],["cw",S(M.w0.t)+1.6,S(M.w1.t)-.2],["cp",S(M.p0.t)+.1,S(M.p1.t)-.2],
@@ -356,6 +404,13 @@ async function seek(t){
356
  for(let i=0;i<N;i++){ const d=((i/N+t*speed/N)%1), z=d*d, y=400+360*z, h=6+70*z, w=4+34*z;
357
  dashes+=`<rect x="${560-w/2}" y="${y}" width="${w}" height="${h}" fill="#F4EFE2" opacity="${.4+.6*d}"/>`; }
358
  $("dashes").innerHTML=car>0?dashes:"";
 
 
 
 
 
 
 
359
  // the conversation, in bubbles: the app reads out the verb, the driver answers, the app says the verdict
360
  const bubs=[["b1",S(M.drive.t)+.5,S(M.turn.t)+.2,60,820],["b2",S(M.turn.t)+.3,S(M.verdict.t)+.1,80,1240],["b3",S(M.verdict.t)+.1,S(M.driveEnd.t)+.3,90,820]];
361
  for(const [id,a,b,x,y] of bubs){ const o=win(t,a,b,.25);
@@ -397,6 +452,7 @@ async function seek(t){
397
  if(o>0&&m.box){ const p0=onPhone(m.box[0]+m.box[2],m.box[1]), x=PHONE.cx+p.tx+p.k*p0.x, y=PHONE.cy+p.ty+p.k*p0.y;
398
  Object.assign(el.style,{right:(1080-x-18)+"px",top:(y-40)+"px",opacity:o,transform:`scale(${lerp(.7,1,o)})`,transformOrigin:"100% 50%"}); }
399
  else el.style.opacity=0; }
 
400
  // the ring on the red letter: after the camera has moved in, until the tip opens
401
  const r=win(t,S(M.score.t)+.9,S(M.tap.t)+.25,.25);
402
  if(r>0){ const b=M.letter.box, p0=onPhone(b[0],b[1]), w=b[2]*SCREEN.w*p.k, h=b[3]*SCREEN.h*p.k, pad=14;
 
29
  .cap h1 em{font-style:normal;color:var(--red)}
30
  .cap p{margin:22px 0 0;font:600 38px/1.35 "Assistant",sans-serif;color:var(--muted)}
31
  .cap p b{color:var(--teal);font-weight:800}
32
+ .cmp{display:flex;align-items:center;justify-content:center;gap:28px;margin-top:22px;direction:rtl}
33
+ .cmp>div{width:330px;padding:16px 10px 12px;border-radius:30px;display:flex;flex-direction:column;align-items:center;gap:2px}
34
+ .cmp b{font:700 92px/1 "Noto Naskh Arabic","Amiri",serif}
35
+ .cmp span{font:800 32px/1.2 "Assistant",sans-serif}
36
+ .cmp small{font:600 24px/1.2 "Assistant",sans-serif;opacity:.85}
37
+ .cmp .was{background:var(--red);color:#fff} .cmp .ok{background:#2F7A4A;color:#fff}
38
+ .cmp i{font:800 56px/1 "Assistant",sans-serif;color:var(--navy);font-style:normal}
39
+ .bub .hl{background:var(--red);color:#fff;border-radius:12px;padding:0 6px}
40
+ .bub .typing{letter-spacing:10px;font-size:52px}
41
+ .bub .lab{display:block;font:700 24px/1.2 "Assistant",sans-serif;opacity:.85;margin-bottom:4px}
42
  /* the phone */
43
  #stage{position:absolute;inset:0;perspective:2600px;perspective-origin:50% 45%}
44
  #phone{position:absolute;left:220px;top:504px;width:641px;height:1353px;transform-style:preserve-3d;will-change:transform}
 
54
  #shadow{position:absolute;left:250px;top:1790px;width:580px;height:90px;border-radius:50%;
55
  background:radial-gradient(closest-side,rgba(31,33,86,.38),rgba(31,33,86,0));filter:blur(6px)}
56
  /* a ring around the red letter, drawn outside the phone's screen so it reads at a glance */
57
+ .word{position:absolute;z-index:8;opacity:0;direction:rtl;white-space:nowrap;font:800 62px/1 "Assistant",sans-serif;color:var(--navy);
58
+ background:#fff;padding:16px 30px;border-radius:999px;box-shadow:0 14px 34px rgba(31,33,86,.28),inset 0 0 0 4px var(--teal)}
59
  #ring{position:absolute;border:7px solid var(--red);border-radius:22px;box-shadow:0 0 0 10px rgba(200,52,74,.18);opacity:0}
60
  /* the opening and closing */
61
  /* the announcement, and the booklet the app follows */
 
74
  #bookShadow{position:absolute;width:1150px;height:200px;border-radius:50%;background:radial-gradient(closest-side,rgba(31,33,86,.30),rgba(31,33,86,0));filter:blur(8px)}
75
  #bookRing{position:absolute;border:7px solid var(--red);border-radius:18px;box-shadow:0 0 0 10px rgba(200,52,74,.18);opacity:0}
76
  /* the summary: what the app is, beside its "הערבית שכבר שלכם" */
77
+ #pills{position:absolute;left:60px;right:60px;top:340px;display:grid;grid-template-columns:1fr 1fr;gap:20px;z-index:6;opacity:0;direction:rtl}
78
+ #pills small{display:block;font:600 26px/1.2 "Assistant",sans-serif;color:var(--muted);margin-top:4px}
79
  #pills div{background:#fff;border-radius:28px;padding:22px 20px;text-align:center;font:700 36px/1.2 "Assistant",sans-serif;color:var(--navy);
80
  box-shadow:0 8px 24px rgba(31,33,86,.10),inset 0 0 0 3px rgba(31,33,86,.06)}
81
  /* the grid of other ways to practice: four small phones, each zooming in for its example */
 
158
  </div>
159
  <div id="band"></div>
160
  <div class="cap" id="cb"><h1>בנויה בדיוק <em style="color:var(--teal)">לפי החוברת</em> 📖</h1><p>כל שיעור בחוברת הוא נושא באפליקציה,<br>עם אותם פעלים ואותם משפטים</p></div>
161
+ <div class="cap" id="c8"><h1>החוברת מלמדת את השפה</h1><p>האפליקציה עוזרת <b>לשלוף, לדבר, לתקן ולזכור</b></p></div>
162
+ <div id="pills"><div>🧠 לשלוף<small>מילים ותרגול יומי</small></div><div>🎙️ לדבר<small>בקול, גם בנסיעה</small></div><div>✍️ לתקן<small>עיצור אחרי עיצור</small></div><div>📿 לזכור<small>חרוזים ומשחקים</small></div></div>
163
  <div class="cap" id="ch"><h1>יודעים <em style="color:var(--teal)">מה לתרגל עכשיו</em></h1><p><span class="nb">1</span> ממשיכים בשיעור של הכיתה<br><span class="nb">2</span> או תרגול קצר, כמה דקות</p></div>
164
  <div class="co" id="co1"><span class="n">1</span></div>
165
  <div class="co" id="co2"><span class="n">2</span></div>
166
  <div class="cap" id="c1"><h1>אומרים בקול</h1><p>רואים פועל בעברית,<br>ואומרים אותו <b>בערבית מדוברת</b></p></div>
167
  <div class="cap" id="c2"><h1>האפליקציה מקשיבה<br>ל<em>כל עיצור</em></h1><p>ומסמנת באדום את ��ה<br>שלא נשמע נכון</p></div>
168
+ <div class="cap" id="c3"><h1>ח׳ במקום <em>ח</em>?</h1>
169
+ <div class="cmp"><div class="was"><b>خ</b><span>ח׳ · חיכוך בחך</span><small>מה שנשמע</small></div><i>←</i>
170
+ <div class="ok"><b>ح</b><span>ח · מהלוע</span><small>מה שצריך</small></div></div></div>
171
+ <div class="cap" id="cl"><h1>שומעים <em style="color:var(--teal)">איך אומרים נכון</em> 🔊</h1><p>ומנסים שוב</p></div>
172
  <div class="cap" id="c4"><h1>מנסים שוב — <em style="color:var(--teal)">100!</em></h1><p>ח נקייה, <b>בדיוק כמו שצריך</b></p></div>
173
  <div class="cap" id="c5"><h1>כל משפט — <em style="color:var(--orange)">חרוז</em> 📿</h1><p>מ־75 ומעלה הוא מזהיב ★<br>ואוספים אותם לאורך כל הדרך</p></div>
174
  <div class="cap" id="c7"><h1>מצב נסיעה 🚗</h1><p>מתרגלים <b>בלי ידיים</b>: האפליקציה מקריאה,<br>מקשיבה ואומרת את הציון בקול</p></div>
 
188
  </div>
189
  </div>
190
  <div id="ring"></div>
191
+ <div class="word" id="wd">דיבר</div><div class="word" id="wp">חַכַּא</div><div class="word" id="wf">יִחְכִּי</div>
192
  <div id="grid">
193
  <div class="tile" id="tw"><div class="ph"><div class="scr"><img alt=""><div class="isl"></div></div></div><div class="lab">📖 מילים חדשות</div></div>
194
  <div class="tile" id="tp"><div class="ph"><div class="scr"><img alt=""><div class="isl"></div></div></div><div class="lab">🧩 זוגות מילים</div></div>
 
200
  <circle cx="170" cy="1930" r="440" fill="none" stroke="#2C2F45" stroke-width="10" stroke-dasharray="40 26" opacity=".7"/>
201
  <path d="M170 1930 L560 1760" stroke="#14151F" stroke-width="70" stroke-linecap="round"/>
202
  </g></svg>
203
+ <div class="bub me" id="sm0"><span class="lab">🎙️ אומרים</span><span class="typing">● ● ●</span></div>
204
+ <div class="bub me" id="sm1"><span class="lab">🎙️ מה שהאפליקציה שמעה</span><span class="tr">חַ'כַּא, יִ<span class="hl">חְ'</span>כִּי</span></div>
205
+ <div class="bub app" id="sa"><span class="lab">🔊 האפליקציה</span><span class="tr">חַכַּא, יִחְכִּי</span></div>
206
+ <div class="bub ok" id="sc"><span class="lab">🎙️ ניסיון שני</span><span class="tr">חַכַּא, יִחְכִּי</span> ✓</div>
207
  <div class="bub app" id="b1">🔊 דיבר — עבר ועתיד</div>
208
  <div class="bub me" id="b2">🎙️ <span class="tr">חַכַּא, יִחְכִּי</span></div>
209
  <div class="bub ok" id="b3">🔊 מצוין! · 100 ✓</div>
 
223
  const $=id=>document.getElementById(id);
224
  // the opening logo, the announcement and the booklet come before the screen; the animated logo closes
225
  // the logo stays up while the description rises under it
226
+ const OPEN_END=4.2, ANN=[1.0,4.2], BOOK0=4.0, BOOK_LEN=8.4, INTRO=BOOK0+BOOK_LEN, CHIPS=0.5, LOGO=6.8, LOGO_SPEED=1.2;
227
  const SLOW=1.15; // the app's screens play 15% slower than recorded, to be read
228
  const PW=520, PH=716; // a booklet page on the frame
229
  const LEAVES=[["cover.jpg",null],["p53.jpg","p54.jpg"],["p55.jpg","p56.jpg"],["p57.jpg","p58.jpg"],["p59.jpg","p60.jpg"]];
 
292
  [S(M.score.t)+.9,focus(lu,lv,1.5,540,1150,{ry:-4,rx:2})],
293
  [S(M.tap.t)+.3,focus(lu,lv,1.5,540,1150,{ry:-3,rx:2})],
294
  [S(M.tap.t)+1.2,focus((lu+tu)/2,(lv+tv)/2,1.22,540,1250,{ry:3,rx:2})],
295
+ [S(M.listen.t)-.1,focus((lu+tu)/2,(lv+tv)/2,1.22,540,1250,{ry:4,rx:2})],
296
+ [S(M.listen.t)+.6,{...rest,ry:6,rx:3}],
297
  [S(M.retry.t)+.7,{...rest,ry:10,rx:3}],
298
  [S(M.great.t),{...rest,ry:8,rx:3}],
299
  [S(M.great.t)+.8,focus(gu,gv,1.3,540,1250,{ry:4,rx:2})],
 
319
  function frameAt(s){ // the last screen frame at or before s
320
  let lo=0, hi=F.length-1; while(lo<hi){ const m=(lo+hi+1)>>1; if(F[m].t<=s) lo=m; else hi=m-1; } return F[lo].file;
321
  }
322
+ /* where the booklet is on the frame at booklet time b (drawBook's camera): page fraction (u,v) -> frame (x,y) */
323
+ function bookAt(b,u,v){
324
+ const lay=ease((b-.9)/1.0), flat=ease((b-4.3)/.9), k0=lerp(lerp(1.16,1.22,ease(b/.9)),.92,lay), zoom=ease((b-5.4)/1.0);
325
+ const k=lerp(lerp(k0,1.55,flat),2.75,zoom), fu=ROW.u+ROW.w/2, fv=ROW.v+ROW.h/2;
326
+ const sx=lerp(lerp(540+PW*k0/2,540,lay),540-k*fu*PW,flat), sy=lerp(lerp(1040,1110,lay),1060-k*(fv-.5)*PH,flat);
327
+ return {x:sx+k*u*PW, y:sy+k*(v-.5)*PH, k};
328
+ }
329
+ /* the three words of the booklet's row fly into the app: off the page as the booklet ends, hovering over the home,
330
+ and into place when the practice opens: דיבר onto the prompt, חכא and יחכי onto the answer's card */
331
+ // דיבר lands when the practice opens; חכא and יחכי wait at the top until the app reveals the answer (after the attempt)
332
+ const WORDS=[{id:"wd",u:.70,to:"promptBox",at:"start"},{id:"wp",u:.41,to:"ans1",at:"score"},{id:"wf",u:.22,to:"ans2",at:"score"}];
333
+ const HOVER=[[730,405],[480,405],[300,405]];
334
+ function drawWords(t,p){
335
+ const S=x=>INTRO+x*SLOW, L0=INTRO-1.3;
336
+ WORDS.forEach((w,i)=>{ const el=$(w.id), land0=S(M[w.at].t)+(w.at==="score"?-.25:.15), land1=land0+.9; // the answer shows just before the score mark
337
+ if(t<L0||t>=land1+.5){ el.style.opacity=0; return; }
338
+ const src=bookAt(Math.min(t,L0)-BOOK0,w.u,ROW.v+ROW.h/2), box=M[w.to].box;
339
+ const p0=onPhone(box[0]+box[2]/2,box[1]+box[3]/2), dst={x:PHONE.cx+p.tx+p.k*p0.x, y:PHONE.cy+p.ty+p.k*p0.y};
340
+ const pop=ease((t-L0)/.35), lift=ease((t-L0-.35)/.8), fly=ease((t-land0)/(land1-land0));
341
+ let x=lerp(src.x,HOVER[i][0],lift), y=lerp(src.y-30*pop,HOVER[i][1],lift);
342
+ x=lerp(x,dst.x,fly); y=lerp(y,dst.y,fly);
343
+ const sc=lerp(lerp(.9,1.25,pop),1,lift)*lerp(1,.62,fly), bob=Math.sin((t-L0)*3+i)*6*(1-fly)*lift;
344
+ Object.assign(el.style,{left:x+"px",top:y+bob+"px",opacity:Math.min(pop,1-ease((t-land1)/.4)),
345
+ transform:`translate(-50%,-50%) scale(${sc}) rotate(${(1-lift)*(i-1)*-4}deg)`}); });
346
+ }
347
  function drawBook(t){
348
  const b=t-BOOK0, on=t>BOOK0-.1&&t<INTRO+.3;
349
  $("bookStage").style.visibility=on?"visible":"hidden"; $("bookRing").style.opacity=0; if(!on) return;
350
  // the booklet: face on and closed (its spine on the right), then lying down, open, flat for a close look at the verb
351
  const lay=ease((b-.9)/1.0), flat=ease((b-4.3)/.9), k0=lerp(lerp(1.16,1.22,ease(b/.9)),.92,lay);
352
+ const zoom=ease((b-5.4)/1.0); // then into the line itself, until it fills the frame
353
+ const k=lerp(lerp(k0,1.55,flat),2.75,zoom), rx=lerp(lerp(0,54,lay),4,flat), rz=lerp(lerp(0,-7,lay),0,flat);
354
  const fu=ROW.u+ROW.w/2, fv=ROW.v+ROW.h/2; // the row comes to the frame's middle
355
  const sx=lerp(lerp(540+PW*k0/2,540,lay),540-k*fu*PW,flat), sy=lerp(lerp(1040,1110,lay),1060-k*(fv-.5)*PH,flat);
356
  $("book").style.transform=`translate(${sx}px,${sy}px) rotateX(${rx}deg) rotateZ(${rz}deg) scale(${k})`;
 
361
  l.style.transform=`rotateY(${ang}deg) translateZ(${l.dataset.z*1.2}px)`;
362
  l.querySelectorAll(".shade").forEach(s=>s.style.opacity=Math.sin(Math.PI*x)*.9); });
363
  // the ring on the row, once the booklet lies flat
364
+ const r=win(b,6.5,BOOK_LEN-.1,.3);
365
  if(r>0){ const x=sx+k*ROW.u*PW, y=sy+k*(ROW.v-.5)*PH, pad=12;
366
  Object.assign($("bookRing").style,{left:x-pad+"px",top:y-pad+"px",width:k*ROW.w*PW+2*pad+"px",height:k*ROW.h*PH+2*pad+"px",
367
  opacity:Math.min(r,out),transform:`scale(${1+.05*Math.sin(b*7)})`}); }
 
386
  $("announce").style.opacity=an; $("announce").style.visibility=an>0?"visible":"hidden";
387
  $("announce").style.transform=`translateY(${(1-rise)*820}px)`;
388
  drawBook(t);
389
+ const caps=[["cb",BOOK0+.9,INTRO-.5],["ch",INTRO-.5,S(M.homeTap.t)+.3],["c1",S(M.start.t)-.1,S(M.score.t)+.2],["c8",S(M.shelf.t)-.1,S(END)+.4],["c2",S(M.score.t)+.2,S(M.tap.t)+.6],["c3",S(M.tap.t)+.6,S(M.listen.t)],["cl",S(M.listen.t),S(M.retry.t)+.4],
390
  ["c4",S(M.retry.t)+.4,S(M.great.t)+1.5],["c5",S(M.great.t)+1.5,S(M.drive.t)-.2],
391
  ["c7",S(M.drive.t)-.2,S(M.driveEnd.t)+.2],
392
  ["cg",S(M.w0.t)-.1,S(M.w0.t)+1.6],["cw",S(M.w0.t)+1.6,S(M.w1.t)-.2],["cp",S(M.p0.t)+.1,S(M.p1.t)-.2],
 
404
  for(let i=0;i<N;i++){ const d=((i/N+t*speed/N)%1), z=d*d, y=400+360*z, h=6+70*z, w=4+34*z;
405
  dashes+=`<rect x="${560-w/2}" y="${y}" width="${w}" height="${h}" fill="#F4EFE2" opacity="${.4+.6*d}"/>`; }
406
  $("dashes").innerHTML=car>0?dashes:"";
407
+ // the core's conversation: the student speaks (typing dots), the app shows what it heard with the consonant
408
+ // marked, says it right, and the second try comes back clean
409
+ const cb=[["sm0",S(M.said_m876.t),S(M.score.t)+.1,60,1530,"me"],["sm1",S(M.score.t)+.1,S(M.listen.t)+.2,60,1530,"me"],
410
+ ["sa",S(M.listen.t)+.2,S(M.retry.t)+.3,60,1530,"app"],["sc",S(M.said_c876.t)+.3,S(M.great.t)+1.6,60,1530,"me"]];
411
+ for(const [id,a,b,x,y,side] of cb){ const o=win(t,a,b,.25);
412
+ Object.assign($(id).style,{[side==="app"?"right":"left"]:x+"px",top:y+"px",opacity:o,transform:`scale(${lerp(.7,1,o)})`,
413
+ transformOrigin:side==="app"?"100% 100%":"0% 100%"}); }
414
  // the conversation, in bubbles: the app reads out the verb, the driver answers, the app says the verdict
415
  const bubs=[["b1",S(M.drive.t)+.5,S(M.turn.t)+.2,60,820],["b2",S(M.turn.t)+.3,S(M.verdict.t)+.1,80,1240],["b3",S(M.verdict.t)+.1,S(M.driveEnd.t)+.3,90,820]];
416
  for(const [id,a,b,x,y] of bubs){ const o=win(t,a,b,.25);
 
452
  if(o>0&&m.box){ const p0=onPhone(m.box[0]+m.box[2],m.box[1]), x=PHONE.cx+p.tx+p.k*p0.x, y=PHONE.cy+p.ty+p.k*p0.y;
453
  Object.assign(el.style,{right:(1080-x-18)+"px",top:(y-40)+"px",opacity:o,transform:`scale(${lerp(.7,1,o)})`,transformOrigin:"100% 50%"}); }
454
  else el.style.opacity=0; }
455
+ drawWords(t,p);
456
  // the ring on the red letter: after the camera has moved in, until the tip opens
457
  const r=win(t,S(M.score.t)+.9,S(M.tap.t)+.25,.25);
458
  if(r>0){ const b=M.letter.box, p0=onPhone(b[0],b[1]), w=b[2]*SCREEN.w*p.k, h=b[3]*SCREEN.h*p.k, pad=14;
scripts/demo_video/promo.py CHANGED
@@ -107,6 +107,7 @@ async def record_screen(url, takes, work):
107
  await wait(150)
108
  print(f" {take}: recording={await recording()} status={await pg.inner_text('#status')!r}")
109
  await pg.evaluate("t=>__demo.play(t)", take)
 
110
  try:
111
  await pg.wait_for_selector("#result:not([hidden])", timeout=30000)
112
  except Exception:
@@ -144,10 +145,15 @@ async def record_screen(url, takes, work):
144
  await pg.evaluate("scrollTo(0,0)"); await wait(200)
145
  await resume()
146
  await mark("start")
147
- await wait(900)
 
 
148
  await mark("mic", "#mic")
149
  await say("m876")
150
  await mark("score", "#result")
 
 
 
151
  bad = '#wordReveals .bad.why[title^="ח (ح) נשמעה כמו ח\'"]'
152
  await mark("letter", bad)
153
  print(" score:", await pg.inner_text("#score"))
@@ -159,6 +165,8 @@ async def record_screen(url, takes, work):
159
  await wait(3600)
160
  await pg.mouse.click(5, 5) # closes the tip
161
  await wait(400)
 
 
162
  await mark("retry", "#mic")
163
  await say("c876", fresh=True)
164
  await pg.evaluate("cancelAutoNext()") # keeps the score on screen
@@ -215,7 +223,7 @@ async def record_screen(url, takes, work):
215
  await wait(2000) # the whole grid first, then the camera moves in
216
  await tap("#learnShow", 1000)
217
  await tap("#learnKnow", 300)
218
- await wait(300); await mark("w1")
219
  pause(); await pg.click("#learnClose"); await wait(500)
220
 
221
  sat = pg.locator("button.sat", has_text="התאימו זוגות").nth(3) # pairs: two matched
@@ -226,7 +234,7 @@ async def record_screen(url, takes, work):
226
  for i in ids[:2]:
227
  await tap(f'.mtile[data-side="he"][data-id="{i}"]', 180)
228
  await tap(f'.mtile[data-side="tr"][data-id="{i}"]', 500)
229
- await wait(300); await mark("p1")
230
  pause(); await pg.click("#matchClose"); await wait(500)
231
 
232
  sat = pg.locator("button.sat", has_text="סיפור: שכני החדש").first # a story, read aloud line by line
@@ -235,7 +243,7 @@ async def record_screen(url, takes, work):
235
  await resume(); await mark("s0")
236
  await wait(400)
237
  await tap("#hearBtn", 0)
238
- await wait(2200); await mark("s1")
239
  pause(); await pg.click("#hearBtn"); await wait(300)
240
  await pg.locator("button", has_text="חזרה למסלול").first.click(); await wait(600)
241
 
@@ -246,7 +254,7 @@ async def record_screen(url, takes, work):
246
  for he, ar in [("פתח", "פַתַח"), ("אכל", "אַכַּל")]:
247
  await tap(f'.mg-bead.he:has-text("{he}")', 200)
248
  await tap(f'.mg-bead.ar:has-text("{ar}")', 480)
249
- await wait(750); await mark("m1") # the camera moves back out to the grid
250
 
251
  # the summary: "the Arabic that's already yours", what this session collected
252
  pause()
 
107
  await wait(150)
108
  print(f" {take}: recording={await recording()} status={await pg.inner_text('#status')!r}")
109
  await pg.evaluate("t=>__demo.play(t)", take)
110
+ await mark("said_" + take) # the student starts speaking (the bubble shows it)
111
  try:
112
  await pg.wait_for_selector("#result:not([hidden])", timeout=30000)
113
  except Exception:
 
145
  await pg.evaluate("scrollTo(0,0)"); await wait(200)
146
  await resume()
147
  await mark("start")
148
+ await mark("promptBox", "#prompt") # where the booklet's words land: the verb, and the answer's card
149
+ await mark("hintBox", "#hint")
150
+ await wait(1500)
151
  await mark("mic", "#mic")
152
  await say("m876")
153
  await mark("score", "#result")
154
+ # the answer is now revealed, word by word: where חכא and יחכי drop in from the top
155
+ await mark("ans1", "#wordReveals .analysis-word:nth-child(1) .expected")
156
+ await mark("ans2", "#wordReveals .analysis-word:nth-child(2) .expected")
157
  bad = '#wordReveals .bad.why[title^="ח (ح) נשמעה כמו ח\'"]'
158
  await mark("letter", bad)
159
  print(" score:", await pg.inner_text("#score"))
 
165
  await wait(3600)
166
  await pg.mouse.click(5, 5) # closes the tip
167
  await wait(400)
168
+ await mark("listen", "#speakBtn") # the right way, said by the app
169
+ await tap("#speakBtn", 2200)
170
  await mark("retry", "#mic")
171
  await say("c876", fresh=True)
172
  await pg.evaluate("cancelAutoNext()") # keeps the score on screen
 
223
  await wait(2000) # the whole grid first, then the camera moves in
224
  await tap("#learnShow", 1000)
225
  await tap("#learnKnow", 300)
226
+ await wait(1600); await mark("w1")
227
  pause(); await pg.click("#learnClose"); await wait(500)
228
 
229
  sat = pg.locator("button.sat", has_text="התאימו זוגות").nth(3) # pairs: two matched
 
234
  for i in ids[:2]:
235
  await tap(f'.mtile[data-side="he"][data-id="{i}"]', 180)
236
  await tap(f'.mtile[data-side="tr"][data-id="{i}"]', 500)
237
+ await wait(1600); await mark("p1")
238
  pause(); await pg.click("#matchClose"); await wait(500)
239
 
240
  sat = pg.locator("button.sat", has_text="סיפור: שכני החדש").first # a story, read aloud line by line
 
243
  await resume(); await mark("s0")
244
  await wait(400)
245
  await tap("#hearBtn", 0)
246
+ await wait(3500); await mark("s1")
247
  pause(); await pg.click("#hearBtn"); await wait(300)
248
  await pg.locator("button", has_text="חזרה למסלול").first.click(); await wait(600)
249
 
 
254
  for he, ar in [("פתח", "פַתַח"), ("אכל", "אַכַּל")]:
255
  await tap(f'.mg-bead.he:has-text("{he}")', 200)
256
  await tap(f'.mg-bead.ar:has-text("{ar}")', 480)
257
+ await wait(2050); await mark("m1") # the camera moves back out to the grid
258
 
259
  # the summary: "the Arabic that's already yours", what this session collected
260
  pause()
static/index.html CHANGED
@@ -2386,9 +2386,11 @@ function homeActions(){ // in this order, the first three that can start: home
2386
  const {l,cats,done}=homeLesson(), d=today();
2387
  const hw=homeworks().filter(h=>!h.t.over).sort((a,b)=>(a.left??1e9)-(b.left??1e9))[0];
2388
  const vids=dailyVideos().filter(v=>v.date===d&&!dailyWatched[v.id]);
2389
- // Keep following the learner's intent: if they left a topic part-way through, resume it even when an earlier
2390
- // topic in the lesson is also unfinished. An untouched or completed remembered topic does not change the path.
2391
- const active=cats.find(c=>c.id===cat&&topicT(c).done>0&&!topicT(c).over), next=active||cats.find(c=>!topicT(c).over);
 
 
2392
  const listed=PHRASES.filter(p=>p.listed!==false), said=CATS.filter(c=>!c.story), mine=cats.filter(c=>!c.story);
2393
  const plan=LH.shortPlan(listed.filter(p=>said.some(c=>c.id===p.cat)),listed.filter(p=>mine.some(c=>c.id===p.cat)),best,EXCELLENT);
2394
  const left=c=>{ const t=topicT(c); return Math.max(1,(t.n||0)-(t.done||0)); }; // what is left of a topic, for its time
@@ -2398,7 +2400,7 @@ function homeActions(){ // in this order, the first three that can start: home
2398
  vids.length>0&&{kind:"video",target:"video:"+vids[0].id,ready:true,title:"הסרטון של היום",go:"צפו",
2399
  detail:vids.length===1?vids[0].title:`${vids.length} סרטונים חדשים`,
2400
  run:()=>{ if(vids.length!==1) return showView("daily"); dailyWatched[vids[0].id]=1; store.set("ihki.dailyWatched",dailyWatched); playVideo(vids[0],true); }},
2401
- next&&{kind:"continue",target:"topic:"+next.id,ready:true,title:`ממשיכים ב${l.name}`,detail:next.name,go:"המשיכו",minutes:LH.minutesFor(left(next)),run:()=>openCat(next)},
2402
  plan&&{kind:"short",target:"short",ready:true,title:"תרגול קצר",minutes:LH.minutesFor(plan.ids.length),
2403
  detail:`${plan.ids.length} משפטים ${plan.kind==="review"?"לשיפור":"חדשים מהשיעור"}`,run:()=>startShort(plan.ids)},
2404
  done&&{kind:"change",target:"change",ready:true,title:"בחירת שיעור אחר",detail:"הכיתה עברה לשיעור הבא?",go:"בחירה",run:()=>askLesson(false)},
@@ -6357,7 +6359,7 @@ function drawTtsCacheControls(box){
6357
  const usage=mark("activity-stats",""), progress=document.createElement("progress"), progressLabel=mark("msg",""), row=mark("row",""), warm=mark("btn primary","הכנת כל ההקלטות","button"), button=mark("btn","מחיקת מטמון ההקלטות","button"), offline=mark("btn","הורדת עותק אופליין","button"), halt=mark("btn","עצירת ההכנה","button"), load=mark("btn","טעינת עותק אופליין","button"), loadFile=document.createElement("input"), msg=mark("msg","");
6358
  loadFile.type="file"; loadFile.accept=".zip,application/zip"; loadFile.hidden=true;
6359
  progress.max=100; progress.value=0; progress.hidden=true; progress.style.width="100%"; progress.setAttribute("aria-label","התקדמות הכנת הקלטות Sawtak"); progressLabel.hidden=true;
6360
- let polling=false, samples=[]; // samples: recent [time, done] while the sweep runs, for the time estimate
6361
  const duration=sec=>{ sec=Math.round(sec); if(sec<60) return "פחות מדקה"; const m=Math.round(sec/60); if(m<60) return `כ־${m} דקות`; const h=Math.floor(m/60),r=m%60; return r?`כ־${h} שעות ו־${r} דקות`:`כ־${h} שעות`; };
6362
  const estimate=s=>{ // from the pace of new recordings over the last 3 minutes (skips are instant and would mislead); else since the start
6363
  const now=Date.now(); if(!s.running){ samples=[]; return s.started_at&&s.total&&s.done===s.total?` · ההכנה ארכה ${duration(now/1000-s.started_at)}`:""; }
@@ -6370,11 +6372,11 @@ function drawTtsCacheControls(box){
6370
  const end=new Date(now+left/rate*1000).toLocaleTimeString("he-IL",{hour:"2-digit",minute:"2-digit"});
6371
  return ` · נותרו ${duration(left/rate)} (סיום משוער ${end})`;
6372
  };
6373
- const warmState=s=>{ if(!s) return; warm.disabled=button.disabled=!!s.running; halt.hidden=!s.running; const pct=s.total?Math.round(100*s.done/s.total):0,voice=s.voice_id?` · קול ${s.voice_id}`:""; progress.hidden=progressLabel.hidden=!s.total; progress.value=pct; progressLabel.textContent=`${pct}% · ${s.done} מתוך ${s.total} · נוצרו ${s.generated}`+(s.to_generate?` מתוך ${s.to_generate} חסרות`:"")+` · כבר היו ${s.skipped}`+(s.bucket?` · הועלו לדלי ${s.uploaded||0}`+(s.upload_failed?` · נכשלו בהעלאה ${s.upload_failed}`:""):" · אין דלי מוגדר (TTS_BUCKET)")+(s.upload_error?` · ${s.upload_error}`:"")+estimate(s); if(s.running) msg.textContent=s.waiting?`Sawtak לא ענה כרגע (הגבלת קצב או תקלת רשת); ניסיון נוסף בעוד ${s.waiting} שניות${voice}`:`מכין הקלטות מול Sawtak${voice}`; else if(s.stopped) msg.textContent=`ההכנה נעצרה לבקשתך אחרי ${s.done} הקלטות. הפעלה חוזרת תמשיך מאותה נקודה${voice}.`; else if(s.error) msg.textContent=`ההכנה נעצרה אחרי ${s.done} הקלטות: ${s.error}${voice}`; else if(s.total&&s.done===s.total) msg.textContent=`המאגר מוכן${voice}.`; if(s.running&&!polling){polling=true; setTimeout(poll,2000);} };
6374
- const show=data=>{ usage.replaceChildren(); const size=data.bytes==null?"סופר…":data.bytes<1024?`${data.bytes} B`:data.bytes<1048576?`${(data.bytes/1024).toFixed(1)} KB`:`${(data.bytes/1048576).toFixed(1)} MB`, rate=data.hit_rate==null?"–":`${Math.round(data.hit_rate*100)}%`; for(const [n,label] of [[data.files??"סופר…","קבצי שמע במטמון"],[size,"נפח בדיסק"],[data.hits,"פגיעות מאז ההפעלה"],[data.misses,"יצירות מאז ההפעלה"],[rate,"שיעור פגיעה"]]){ const stat=mark("activity-stat",""); stat.append(mark("",n,"b"),mark("",label,"small")); usage.append(stat); } if(!data.available) msg.textContent="Sawtak אינו מוגדר בשרת."; };
6375
  const poll=()=>api("/api/tts-cache").then(async res=>{const data=await res.json().catch(()=>({}));if(!res.ok) throw new Error(data.detail||res.status);show(data);polling=false;warmState(data.warm);if((data.files==null||data.bytes==null)&&!polling&&card.isConnected){polling=true;setTimeout(poll,2000);}}).catch(err=>{msg.textContent="נתוני המטמון לא זמינים כרגע ("+err.message+"), מנסה שוב…";if(card.isConnected) setTimeout(poll,5000); else polling=false;});
6376
  poll();
6377
- warm.onclick=async()=>{ const voice=(prompt("מזהה הקול של Sawtak:","273d81b2-991b-5b76-8525-eeacc6a3d759")||"").trim(); if(!voice) return; if(!confirm(`להכין עכשיו את כל הקלטות Sawtak החסרות לקול ${voice}? התהליך משתמש ביתרה בחשבון וממשיך ברקע.`)) return; warm.disabled=true; msg.textContent="מתחיל…"; try{const res=await api("/api/tts-cache/warm",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voice})}),data=await res.json().catch(()=>({}));if(!res.ok) throw new Error(data.detail||res.status);warmState(data);}catch(err){warm.disabled=false;msg.textContent="ההכנה לא התחילה: "+err.message;} };
6378
  halt.hidden=true;
6379
  halt.onclick=async()=>{
6380
  halt.disabled=true; msg.textContent="עוצר אחרי ההקלטה הנוכחית…";
 
2386
  const {l,cats,done}=homeLesson(), d=today();
2387
  const hw=homeworks().filter(h=>!h.t.over).sort((a,b)=>(a.left??1e9)-(b.left??1e9))[0];
2388
  const vids=dailyVideos().filter(v=>v.date===d&&!dailyWatched[v.id]);
2389
+ // `cat` is written as soon as a topic opens, so it is the strongest record of what the learner meant to do next.
2390
+ // Resume it even before its first saved score (notably, a story left on its first line), and even when the learner
2391
+ // opened it from an earlier lesson than the class's current one. Once it is complete, return to the current lesson.
2392
+ const active=CATS.find(c=>c.id===cat&&!topicT(c).over), next=active||cats.find(c=>!topicT(c).over);
2393
+ const nextLesson=next&&LESSONS.find(x=>x.id===next.lesson);
2394
  const listed=PHRASES.filter(p=>p.listed!==false), said=CATS.filter(c=>!c.story), mine=cats.filter(c=>!c.story);
2395
  const plan=LH.shortPlan(listed.filter(p=>said.some(c=>c.id===p.cat)),listed.filter(p=>mine.some(c=>c.id===p.cat)),best,EXCELLENT);
2396
  const left=c=>{ const t=topicT(c); return Math.max(1,(t.n||0)-(t.done||0)); }; // what is left of a topic, for its time
 
2400
  vids.length>0&&{kind:"video",target:"video:"+vids[0].id,ready:true,title:"הסרטון של היום",go:"צפו",
2401
  detail:vids.length===1?vids[0].title:`${vids.length} סרטונים חדשים`,
2402
  run:()=>{ if(vids.length!==1) return showView("daily"); dailyWatched[vids[0].id]=1; store.set("ihki.dailyWatched",dailyWatched); playVideo(vids[0],true); }},
2403
+ next&&{kind:"continue",target:"topic:"+next.id,ready:true,title:`ממשיכים ב${nextLesson?.name||l.name}`,detail:next.name,go:"המשיכו",minutes:LH.minutesFor(left(next)),run:()=>openCat(next)},
2404
  plan&&{kind:"short",target:"short",ready:true,title:"תרגול קצר",minutes:LH.minutesFor(plan.ids.length),
2405
  detail:`${plan.ids.length} משפטים ${plan.kind==="review"?"לשיפור":"חדשים מהשיעור"}`,run:()=>startShort(plan.ids)},
2406
  done&&{kind:"change",target:"change",ready:true,title:"בחירת שיעור אחר",detail:"הכיתה עברה לשיעור הבא?",go:"בחירה",run:()=>askLesson(false)},
 
6359
  const usage=mark("activity-stats",""), progress=document.createElement("progress"), progressLabel=mark("msg",""), row=mark("row",""), warm=mark("btn primary","הכנת כל ההקלטות","button"), button=mark("btn","מחיקת מטמון ההקלטות","button"), offline=mark("btn","הורדת עותק אופליין","button"), halt=mark("btn","עצירת ההכנה","button"), load=mark("btn","טעינת עותק אופליין","button"), loadFile=document.createElement("input"), msg=mark("msg","");
6360
  loadFile.type="file"; loadFile.accept=".zip,application/zip"; loadFile.hidden=true;
6361
  progress.max=100; progress.value=0; progress.hidden=true; progress.style.width="100%"; progress.setAttribute("aria-label","התקדמות הכנת הקלטות Sawtak"); progressLabel.hidden=true;
6362
+ let polling=false, samples=[], configuredVoice="273d81b2-991b-5b76-8525-eeacc6a3d759"; // samples: recent [time, done] while the sweep runs, for the time estimate
6363
  const duration=sec=>{ sec=Math.round(sec); if(sec<60) return "פחות מדקה"; const m=Math.round(sec/60); if(m<60) return `כ־${m} דקות`; const h=Math.floor(m/60),r=m%60; return r?`כ־${h} שעות ו־${r} דקות`:`כ־${h} שעות`; };
6364
  const estimate=s=>{ // from the pace of new recordings over the last 3 minutes (skips are instant and would mislead); else since the start
6365
  const now=Date.now(); if(!s.running){ samples=[]; return s.started_at&&s.total&&s.done===s.total?` · ההכנה ארכה ${duration(now/1000-s.started_at)}`:""; }
 
6372
  const end=new Date(now+left/rate*1000).toLocaleTimeString("he-IL",{hour:"2-digit",minute:"2-digit"});
6373
  return ` · נותרו ${duration(left/rate)} (סיום משוער ${end})`;
6374
  };
6375
+ const warmState=s=>{ if(!s) return; warm.disabled=button.disabled=!!s.running; halt.toggleAttribute("hidden",!s.running); const pct=s.total?Math.round(100*s.done/s.total):0,voice=s.voice_id?` · קול ${s.voice_id}`:""; progress.toggleAttribute("hidden",!s.total); progressLabel.toggleAttribute("hidden",!s.total); progress.value=pct; progressLabel.textContent=`${pct}% · ${s.done} מתוך ${s.total} · נוצרו ${s.generated}`+(s.to_generate?` מתוך ${s.to_generate} חסרות`:"")+` · כבר היו ${s.skipped}`+(s.bucket?` · הועלו לדלי ${s.uploaded||0}`+(s.upload_failed?` · נכשלו בהעלאה ${s.upload_failed}`:""):" · אין דלי מוגדר (TTS_BUCKET)")+(s.upload_error?` · ${s.upload_error}`:"")+estimate(s); if(s.running) msg.textContent=s.waiting?`Sawtak לא ענה כרגע (הגבלת קצב או תקלת רשת); ניסיון נוסף בעוד ${s.waiting} שניות${voice}`:`מכין הקלטות מול Sawtak${voice}`; else if(s.stopped) msg.textContent=`ההכנה נעצרה לבקשתך אחרי ${s.done} הקלטות. הפעלה חוזרת תמשיך מאותה נקודה${voice}.`; else if(s.error) msg.textContent=`ההכנה נעצרה אחרי ${s.done} הקלטות: ${s.error}${voice}`; else if(s.total&&s.done===s.total) msg.textContent=`המאגר מוכן${voice}.`; if(s.running&&!polling){polling=true; setTimeout(poll,2000);} };
6376
+ const show=data=>{ if(data.voice_id) configuredVoice=data.voice_id; usage.replaceChildren(); const size=data.bytes==null?"סופר…":data.bytes<1024?`${data.bytes} B`:data.bytes<1048576?`${(data.bytes/1024).toFixed(1)} KB`:`${(data.bytes/1048576).toFixed(1)} MB`, rate=data.hit_rate==null?"–":`${Math.round(data.hit_rate*100)}%`; for(const [n,label] of [[data.files??"סופר…","קבצי שמע במטמון"],[size,"נפח בדיסק"],[data.hits,"פגיעות מאז ההפעלה"],[data.misses,"יצירות מאז ההפעלה"],[rate,"שיעור פגיעה"]]){ const stat=mark("activity-stat",""); stat.append(mark("",n,"b"),mark("",label,"small")); usage.append(stat); } if(!data.available) msg.textContent="Sawtak אינו מוגדר בשרת."; };
6377
  const poll=()=>api("/api/tts-cache").then(async res=>{const data=await res.json().catch(()=>({}));if(!res.ok) throw new Error(data.detail||res.status);show(data);polling=false;warmState(data.warm);if((data.files==null||data.bytes==null)&&!polling&&card.isConnected){polling=true;setTimeout(poll,2000);}}).catch(err=>{msg.textContent="נתוני המטמון לא זמינים כרגע ("+err.message+"), מנסה שוב…";if(card.isConnected) setTimeout(poll,5000); else polling=false;});
6378
  poll();
6379
+ warm.onclick=async()=>{ const voice=(prompt("מזהה הקול של Sawtak:",configuredVoice)||"").trim(); if(!voice) return; if(!confirm(`להכין עכשיו את כל הקלטות Sawtak החסרות לקול ${voice}? התהליך משתמש ביתרה בחשבון וממשיך ברקע.`)) return; warm.disabled=true; msg.textContent="מתחיל…"; let data; try{const res=await api("/api/tts-cache/warm",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voice})});data=await res.json().catch(()=>({}));if(!res.ok) throw new Error(data.detail||res.status);}catch(err){warm.disabled=false;msg.textContent="ההכנה לא התחילה: "+err.message;return;} try{warmState(data);}catch(err){console.error("Could not display TTS warm state",err);msg.textContent="ההכנה התחילה. מרענן את נתוני ההתקדמות…";polling=false;setTimeout(poll,500);} };
6380
  halt.hidden=true;
6381
  halt.onclick=async()=>{
6382
  halt.disabled=true; msg.textContent="עוצר אחרי ההקלטה הנוכחית…";
tests/test_arabic_diacritics.py CHANGED
@@ -116,14 +116,16 @@ def test_flat_sawtak_cache_is_migrated_into_voice_folder(tmp_path):
116
  assert data == b"old recording" and nested.exists() and not legacy.exists()
117
 
118
 
119
- def test_only_raef_is_offered_and_is_the_configured_default(monkeypatch):
120
  from app.tts import RAEF_VOICE_ID, VOICES, load_tts
121
 
122
  monkeypatch.setenv("SAWTAK_API_KEY", "key")
123
- monkeypatch.setenv("SAWTAK_VOICE_ID", "an-old-different-voice")
124
  tts = load_tts()
125
  assert VOICES == {"רַאאִף": RAEF_VOICE_ID}
126
- assert tts.voice_id == RAEF_VOICE_ID
 
 
127
 
128
 
129
 
 
116
  assert data == b"old recording" and nested.exists() and not legacy.exists()
117
 
118
 
119
+ def test_sawtak_voice_id_configures_sentence_playback(monkeypatch):
120
  from app.tts import RAEF_VOICE_ID, VOICES, load_tts
121
 
122
  monkeypatch.setenv("SAWTAK_API_KEY", "key")
123
+ monkeypatch.setenv("SAWTAK_VOICE_ID", "a-different-voice")
124
  tts = load_tts()
125
  assert VOICES == {"רַאאִף": RAEF_VOICE_ID}
126
+ assert tts.voice_id == "a-different-voice"
127
+ tts._wav = lambda _text, voice, **_kwargs: (b"audio", voice)
128
+ assert tts.wav("مَرْحَبَا") == (b"audio", "a-different-voice")
129
 
130
 
131
 
tests/test_learner_home_browser.py CHANGED
@@ -463,6 +463,28 @@ def test_home_resumes_the_partly_started_topic_instead_of_the_first_unfinished_o
463
  assert r["proposed"] == r["started"]
464
 
465
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  PRIORITY = """
467
  await sleep(1500);
468
  const titles=()=>[...document.querySelectorAll("#vids .focus-action strong")].map(s=>s.textContent);
 
463
  assert r["proposed"] == r["started"]
464
 
465
 
466
+ RESUME_OPEN_STORY = """
467
+ await sleep(1500);
468
+ const current=LESSONS.find(l=>l.id===currentLesson());
469
+ const started=CATS.find(c=>c.story&&c.lesson!==current.id&&PHRASES.some(p=>p.cat===c.id&&p.listed!==false));
470
+ if(!started) throw new Error("the class needs a story in an earlier lesson");
471
+ cat=started.id; store.set("ihki.cat",cat); // opened, but left before the first line received a score
472
+ drawNav();
473
+ const action=[...document.querySelectorAll("#vids .focus-action")].find(b=>b.textContent.includes("ממשיכים ב"));
474
+ out.story=started.name;
475
+ out.lesson=LESSONS.find(l=>l.id===started.lesson).name;
476
+ out.proposed=action.querySelector(".focus-detail").textContent;
477
+ out.title=action.querySelector("strong").textContent;
478
+ """
479
+
480
+
481
+ def test_home_resumes_an_open_story_before_its_first_score_even_from_an_earlier_lesson(site):
482
+ site.auth.rows[("dana@x.com", 7)] = {"current_lesson": "5"}
483
+ r = site("dana@x.com", RESUME_OPEN_STORY)
484
+ assert r["proposed"] == r["story"]
485
+ assert r["lesson"] in r["title"]
486
+
487
+
488
  PRIORITY = """
489
  await sleep(1500);
490
  const titles=()=>[...document.querySelectorAll("#vids .focus-action strong")].map(s=>s.textContent);
tests/test_scoring.py CHANGED
@@ -521,7 +521,8 @@ def test_ana_explicitly_accepts_the_natural_vowell_initial_form():
521
  assert not r["tips"] and not r["issues"]
522
 
523
 
524
- def test_tts_endpoint_serves_phrase_audio_and_503_without_key():
 
525
  from fastapi.testclient import TestClient
526
 
527
  from app.main import create_app
@@ -541,9 +542,9 @@ def test_tts_endpoint_serves_phrase_audio_and_503_without_key():
541
  assert r.status_code == 200 and r.headers["content-type"] == "audio/wav" and r.content == b"RIFFfake"
542
  assert r.headers["x-tts-provider"] == "sawtakarabi.ai" and r.headers["x-tts-voice-id"] == FakeTTS.voice_id
543
 
544
- from app.tts import VOICES
545
  voices = c.get("/api/phrases").json()["voices"]
546
- assert [v["name"] for v in voices] == list(VOICES)
547
  assert voices == [{"id": FakeTTS.voice_id, "name": "רַאאִף"}]
548
  assert c.get("/api/tts/9999").status_code == 404
549
  health = c.get("/api/health").json()
@@ -2894,3 +2895,13 @@ def test_a_vowel_heard_with_a_tone_number_is_the_vowel():
2894
  def test_another_vowel_with_a_tone_number_is_still_wrong():
2895
  r = split_vowel_scorer("u", "u5", 0.45).score(None, HIYYE)
2896
  assert r["words"][0]["phonemes"][1]["score"] < 50
 
 
 
 
 
 
 
 
 
 
 
521
  assert not r["tips"] and not r["issues"]
522
 
523
 
524
+ def test_tts_endpoint_serves_phrase_audio_and_503_without_key(monkeypatch):
525
+ monkeypatch.delenv("SAWTAK_VOICE_ID", raising=False) # a developer's .env may set a playback voice
526
  from fastapi.testclient import TestClient
527
 
528
  from app.main import create_app
 
542
  assert r.status_code == 200 and r.headers["content-type"] == "audio/wav" and r.content == b"RIFFfake"
543
  assert r.headers["x-tts-provider"] == "sawtakarabi.ai" and r.headers["x-tts-voice-id"] == FakeTTS.voice_id
544
 
545
+ from app.tts import playback_voices
546
  voices = c.get("/api/phrases").json()["voices"]
547
+ assert [v["name"] for v in voices] == list(playback_voices())
548
  assert voices == [{"id": FakeTTS.voice_id, "name": "רַאאִף"}]
549
  assert c.get("/api/tts/9999").status_code == 404
550
  health = c.get("/api/health").json()
 
2895
  def test_another_vowel_with_a_tone_number_is_still_wrong():
2896
  r = split_vowel_scorer("u", "u5", 0.45).score(None, HIYYE)
2897
  assert r["words"][0]["phonemes"][1]["score"] < 50
2898
+
2899
+
2900
+ def test_the_sawtak_voice_id_is_offered_for_playback_by_name(monkeypatch):
2901
+ from app.tts import BASELINE_VOICES, VOICES, playback_voices, voice_name
2902
+ monkeypatch.setenv("SAWTAK_VOICE_ID", "amir-voice-id")
2903
+ monkeypatch.delenv("SAWTAK_VOICE_NAME", raising=False)
2904
+ voices = playback_voices()
2905
+ assert list(voices)[: len(VOICES)] == list(VOICES) and voices["אַמִיר"] == "amir-voice-id"
2906
+ assert voice_name("amir-voice-id") == "אמיר"
2907
+ assert "amir-voice-id" not in BASELINE_VOICES.values() # playback only: scores keep their baseline