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 +5 -0
- README.md +2 -2
- app/main.py +9 -6
- app/tts.py +46 -11
- openspec/changes/add-grok-library-mcp/.openspec.yaml +2 -0
- scripts/demo_video/README.md +10 -7
- scripts/demo_video/promo.html +65 -9
- scripts/demo_video/promo.py +13 -5
- static/index.html +10 -8
- tests/test_arabic_diacritics.py +5 -3
- tests/test_learner_home_browser.py +22 -0
- tests/test_scoring.py +14 -3
.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 |
-
-
|
| 790 |
- Without a key the endpoint answers 503 and the page falls back to the device's Arabic voice.
|
| 791 |
-
-
|
| 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,
|
|
|
|
| 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 |
-
#
|
| 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
|
| 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
|
| 2770 |
-
|
|
|
|
| 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,
|
|
|
|
| 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
|
| 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 {**
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 434 |
-
voice = voice if voice in LINE_VOICES.values() else
|
| 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 |
-
|
|
|
|
| 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 (
|
| 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
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
the
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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>
|
| 149 |
-
<div id="pills"><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>
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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.
|
|
|
|
| 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
|
|
|
|
| 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,
|
| 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
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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 |
-
//
|
| 2390 |
-
//
|
| 2391 |
-
|
|
|
|
|
|
|
| 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
|
| 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:",
|
| 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
|
| 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", "
|
| 124 |
tts = load_tts()
|
| 125 |
assert VOICES == {"רַאאִף": RAEF_VOICE_ID}
|
| 126 |
-
assert tts.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
|
| 545 |
voices = c.get("/api/phrases").json()["voices"]
|
| 546 |
-
assert [v["name"] for v in voices] == list(
|
| 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
|