feat(tts): implement tts_arabic function for improved Arabic pronunciation handling and update references
Browse files- app/arabic_diacritics.py +28 -0
- app/main.py +6 -6
- app/reference.py +2 -2
- scripts/demo_video/promo.html +67 -3
- scripts/demo_video/promo.py +15 -5
- scripts/minerva_benchmark.py +2 -2
- tests/test_arabic_diacritics.py +12 -0
app/arabic_diacritics.py
CHANGED
|
@@ -104,3 +104,31 @@ def point_arabic(arabic: str, ipa: str) -> str:
|
|
| 104 |
return result
|
| 105 |
|
| 106 |
return re.sub(r"[^\s]+", point, arabic) if len(arabic.split()) == len(ipa_words) else arabic
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
return result
|
| 105 |
|
| 106 |
return re.sub(r"[^\s]+", point, arabic) if len(arabic.split()) == len(ipa_words) else arabic
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# Spellings for the voice only, never shown: Sawtak reads the pointed ููููู / ููููู as "hu" / "hi". It reads these as
|
| 110 |
+
# huwwe / hiyye: ูููุฉ like the -e of ูููุฉ, ูููููู with the -e as a written yฤ'. Checked with the phoneme model
|
| 111 |
+
# (the pronoun alone: 58 -> 95 and 65 -> 95).
|
| 112 |
+
_TTS_RESPELL = {("ูู", "h u w w e"): "ูููุฉ", ("ูู", "h i j j e"): "ูููููู"}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def tts_arabic(arabic: str, ipa: str) -> str:
|
| 116 |
+
"""The text sent to the Arabic voice: point_arabic, with the words it misreads respelled."""
|
| 117 |
+
ipa_words = [part.strip() for part in ipa.split("|")]
|
| 118 |
+
if len(arabic.split()) != len(ipa_words):
|
| 119 |
+
return point_arabic(arabic, ipa)
|
| 120 |
+
words = iter(ipa_words)
|
| 121 |
+
|
| 122 |
+
def say(match: re.Match[str]) -> str:
|
| 123 |
+
word, ipa_word = match.group(0), next(words)
|
| 124 |
+
bare = _MARKS.sub("", word)
|
| 125 |
+
tail = re.search(r"[ุ?!.,ุ]*$", bare).group(0) # punctuation stuck to the word stays after it
|
| 126 |
+
core = bare[:len(bare) - len(tail)] if tail else bare
|
| 127 |
+
if core.startswith("ู") and ipa_word.startswith("u "): # ูููู "and she": the ู, then the pronoun
|
| 128 |
+
respelled = _TTS_RESPELL.get((core[1:], ipa_word[2:]))
|
| 129 |
+
if respelled:
|
| 130 |
+
return "ูู" + respelled + tail
|
| 131 |
+
respelled = _TTS_RESPELL.get((core, ipa_word))
|
| 132 |
+
return respelled + tail if respelled else _point_word(word, ipa_word)
|
| 133 |
+
|
| 134 |
+
return re.sub(r"[^\s]+", say, arabic)
|
app/main.py
CHANGED
|
@@ -40,7 +40,7 @@ from fastapi import BackgroundTasks, Body, Depends, FastAPI, File, Form, HTTPExc
|
|
| 40 |
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
| 41 |
from fastapi.staticfiles import StaticFiles
|
| 42 |
|
| 43 |
-
from .arabic_diacritics import point_arabic
|
| 44 |
from . import lab
|
| 45 |
from .auth import AuthUnavailable, ClassConflict, MAX_MONITOR_ATTEMPTS, SupabaseAuth, load_auth
|
| 46 |
from .keystore import (CREATE_APP_SECRETS, MAX_LEN as MAX_KEY_LEN, MIGRATE_APP_SECRETS, NAMES as KEY_NAMES, KeyRefused,
|
|
@@ -1397,7 +1397,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 1397 |
if app.state.tts is None:
|
| 1398 |
raise HTTPException(503, "ืืื TTS ืืฉืจืช ืืื (SAWTAK_API_KEY ืื ืืืืืจ)")
|
| 1399 |
try:
|
| 1400 |
-
data, _ = app.state.tts.wav(
|
| 1401 |
except TTSUnavailable as e:
|
| 1402 |
raise HTTPException(503, f"ื-TTS ืื ืืืื: {e}")
|
| 1403 |
return found, data
|
|
@@ -1610,7 +1610,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 1610 |
if refs is None:
|
| 1611 |
return []
|
| 1612 |
refs.get(phrase["key"], phrase)
|
| 1613 |
-
text, out =
|
| 1614 |
for voice in refs.voices:
|
| 1615 |
try:
|
| 1616 |
if not refs.tts.cached(text, voice):
|
|
@@ -2578,7 +2578,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 2578 |
"""The model answer spoken by Raef, or by a dialogue line's own voice. 503 tells the page to use the device voice."""
|
| 2579 |
names = {i: n for n, i in {**VOICES, **LINE_VOICES}.items()}
|
| 2580 |
phrase = phrase_of(phrase_id)
|
| 2581 |
-
text =
|
| 2582 |
if app.state.tts is None:
|
| 2583 |
raise HTTPException(503, "tts not configured")
|
| 2584 |
try:
|
|
@@ -2702,7 +2702,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 2702 |
names = {i: n for n, i in {**VOICES, **LINE_VOICES}.items()}
|
| 2703 |
known: dict[str, dict] = {} # file name (sha1 of version, voice and text) -> what it says
|
| 2704 |
for phrase_id, phrase in enumerate(PHRASES):
|
| 2705 |
-
text =
|
| 2706 |
for voice in names:
|
| 2707 |
info = known.setdefault(Path(tts.cache_key(text, voice)).name,
|
| 2708 |
{"voice_id": voice, "voice": names[voice], "text": text,
|
|
@@ -2757,7 +2757,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 2757 |
return dict(app.state.tts_warm)
|
| 2758 |
jobs, seen = [], set()
|
| 2759 |
for phrase in PHRASES:
|
| 2760 |
-
text =
|
| 2761 |
if text not in seen:
|
| 2762 |
seen.add(text); jobs.append(text)
|
| 2763 |
mirror = getattr(tts, "mirror", None)
|
|
|
|
| 40 |
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
| 41 |
from fastapi.staticfiles import StaticFiles
|
| 42 |
|
| 43 |
+
from .arabic_diacritics import point_arabic, tts_arabic
|
| 44 |
from . import lab
|
| 45 |
from .auth import AuthUnavailable, ClassConflict, MAX_MONITOR_ATTEMPTS, SupabaseAuth, load_auth
|
| 46 |
from .keystore import (CREATE_APP_SECRETS, MAX_LEN as MAX_KEY_LEN, MIGRATE_APP_SECRETS, NAMES as KEY_NAMES, KeyRefused,
|
|
|
|
| 1397 |
if app.state.tts is None:
|
| 1398 |
raise HTTPException(503, "ืืื TTS ืืฉืจืช ืืื (SAWTAK_API_KEY ืื ืืืืืจ)")
|
| 1399 |
try:
|
| 1400 |
+
data, _ = app.state.tts.wav(tts_arabic(*found["variants"][0]), LAB_TTS_VOICES[voice])
|
| 1401 |
except TTSUnavailable as e:
|
| 1402 |
raise HTTPException(503, f"ื-TTS ืื ืืืื: {e}")
|
| 1403 |
return found, data
|
|
|
|
| 1610 |
if refs is None:
|
| 1611 |
return []
|
| 1612 |
refs.get(phrase["key"], phrase)
|
| 1613 |
+
text, out = tts_arabic(*phrase["variants"][0]), []
|
| 1614 |
for voice in refs.voices:
|
| 1615 |
try:
|
| 1616 |
if not refs.tts.cached(text, voice):
|
|
|
|
| 2578 |
"""The model answer spoken by Raef, or by a dialogue line's own voice. 503 tells the page to use the device voice."""
|
| 2579 |
names = {i: n for n, i in {**VOICES, **LINE_VOICES}.items()}
|
| 2580 |
phrase = phrase_of(phrase_id)
|
| 2581 |
+
text = tts_arabic(*phrase["variants"][0])
|
| 2582 |
if app.state.tts is None:
|
| 2583 |
raise HTTPException(503, "tts not configured")
|
| 2584 |
try:
|
|
|
|
| 2702 |
names = {i: n for n, i in {**VOICES, **LINE_VOICES}.items()}
|
| 2703 |
known: dict[str, dict] = {} # file name (sha1 of version, voice and text) -> what it says
|
| 2704 |
for phrase_id, phrase in enumerate(PHRASES):
|
| 2705 |
+
text = tts_arabic(*phrase["variants"][0])
|
| 2706 |
for voice in names:
|
| 2707 |
info = known.setdefault(Path(tts.cache_key(text, voice)).name,
|
| 2708 |
{"voice_id": voice, "voice": names[voice], "text": text,
|
|
|
|
| 2757 |
return dict(app.state.tts_warm)
|
| 2758 |
jobs, seen = [], set()
|
| 2759 |
for phrase in PHRASES:
|
| 2760 |
+
text = tts_arabic(*phrase["variants"][0])
|
| 2761 |
if text not in seen:
|
| 2762 |
seen.add(text); jobs.append(text)
|
| 2763 |
mirror = getattr(tts, "mirror", None)
|
app/reference.py
CHANGED
|
@@ -17,7 +17,7 @@ import time
|
|
| 17 |
from dataclasses import replace
|
| 18 |
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
| 19 |
|
| 20 |
-
from .arabic_diacritics import
|
| 21 |
from .scorer import Reference, Scorer, decode_audio
|
| 22 |
from .tts import voice_name
|
| 23 |
|
|
@@ -44,7 +44,7 @@ class References:
|
|
| 44 |
|
| 45 |
def get(self, phrase_id: int, phrase: dict) -> list[Reference]:
|
| 46 |
"""The references of a phrase that are ready within the wait."""
|
| 47 |
-
text =
|
| 48 |
jobs: list[tuple[tuple[int, str], Future]] = []
|
| 49 |
with self._lock:
|
| 50 |
for n, voice in enumerate(self.voices):
|
|
|
|
| 17 |
from dataclasses import replace
|
| 18 |
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
| 19 |
|
| 20 |
+
from .arabic_diacritics import tts_arabic
|
| 21 |
from .scorer import Reference, Scorer, decode_audio
|
| 22 |
from .tts import voice_name
|
| 23 |
|
|
|
|
| 44 |
|
| 45 |
def get(self, phrase_id: int, phrase: dict) -> list[Reference]:
|
| 46 |
"""The references of a phrase that are ready within the wait."""
|
| 47 |
+
text = tts_arabic(*phrase["variants"][0])
|
| 48 |
jobs: list[tuple[tuple[int, str], Future]] = []
|
| 49 |
with self._lock:
|
| 50 |
for n, voice in enumerate(self.voices):
|
scripts/demo_video/promo.html
CHANGED
|
@@ -57,17 +57,51 @@ body{font-family:"Assistant",system-ui,sans-serif;color:var(--navy)}
|
|
| 57 |
.chip.hot{background:var(--red)} .chip.hot b,.chip.hot span{color:#fff}
|
| 58 |
#outro img{width:560px}
|
| 59 |
#outro .cta{font:400 54px/1 "Secular One","Assistant",sans-serif;color:#fff;background:var(--navy);padding:26px 56px;border-radius:999px}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</style>
|
| 61 |
</head>
|
| 62 |
<body>
|
| 63 |
<div id="bg"></div>
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
<div id="band"></div>
|
| 66 |
<div class="cap" id="c1"><h1>ืืืืจืื ืืงืื</h1><p>ืจืืืื ืคืืขื ืืขืืจืืช,<br>ืืืืืจืื ืืืชื <b>ืืขืจืืืช ืืืืืจืช</b></p></div>
|
| 67 |
<div class="cap" id="c2"><h1>ืืืคืืืงืฆืื ืืงืฉืืื<br>ื<em>ืื ืขืืฆืืจ</em></h1><p>ืืืกืื ืช ืืืืื ืืช ืื<br>ืฉืื ื ืฉืืข ื ืืื</p></div>
|
| 68 |
<div class="cap" id="c3"><h1>ืืณ ืืืงืื <em>ื</em>?</h1><p>ืืืค ืฉื ืืชื <b>ืืืืืจื ืขืืจืืช</b>:<br>ืื ื ืฉืืข, ืืืื ืืชืงื ืื</p></div>
|
| 69 |
<div class="cap" id="c4"><h1>ืื ืกืื ืฉืื โ <em style="color:var(--teal)">100!</em></h1><p>ื ื ืงืืื, <b>ืืืืืง ืืื ืฉืฆืจืื</b></p></div>
|
| 70 |
<div class="cap" id="c5"><h1>ืื ืืฉืคื โ <em style="color:var(--orange)">ืืจืื</em> ๐ฟ</h1><p>ืึพ75 ืืืขืื ืืื ืืืืื โ
<br>ืืืืกืคืื ืืืชื ืืืืจื ืื ืืืจื</p></div>
|
|
|
|
| 71 |
<div class="cap" id="c6"><h1>ืืืฉืืงืื: <em style="color:var(--teal)">ืืคืืขื ืืืจืื</em></h1><p>ืืืคืืื ืืจืืืื ืืืืฆืืื ืืืืืช<br>ืฉื ืคืขืืื ืืขืืจืืช ืืืขืจืืืช</p></div>
|
| 72 |
|
| 73 |
<div id="stage">
|
|
@@ -80,6 +114,15 @@ body{font-family:"Assistant",system-ui,sans-serif;color:var(--navy)}
|
|
| 80 |
</div>
|
| 81 |
</div>
|
| 82 |
<div id="ring"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
<div id="outro">
|
| 85 |
<h2>ืืขืจืืืช ืฉื ืืืืจื ืขืืจืืช<br>ื ืฉืืขืช ืืืจืช</h2>
|
|
@@ -122,6 +165,7 @@ async function setup(d){
|
|
| 122 |
LOGO_ANIMS=$("logoSvg").getAnimations({subtree:true}); LOGO_ANIMS.forEach(a=>a.pause());
|
| 123 |
const S=t=>INTRO+t;
|
| 124 |
const rest={tx:0,ty:0,k:1,rx:4,ry:-16,rz:0}, away={tx:0,ty:1500,k:.9,rx:20,ry:-30,rz:-6};
|
|
|
|
| 125 |
const [lu,lv]=center(M.letter.box), [tu,tv]=M.tip&&M.tip.box&&M.tip.box[3]>0?center(M.tip.box):[.5,.85];
|
| 126 |
const [gu,gv]=center(M.great.box);
|
| 127 |
KEYS=[
|
|
@@ -137,8 +181,10 @@ async function setup(d){
|
|
| 137 |
[S(M.great.t)+.8,focus(gu,gv,1.3,540,1250,{ry:4,rx:2})],
|
| 138 |
[S(M.great.t)+1.6,focus(gu,gv,1.3,540,1250,{ry:2,rx:2})],
|
| 139 |
[S(M.great.t)+2.4,focus(.5,.3,1.18,540,1150,{ry:-3,rx:2})],
|
| 140 |
-
[S(M.
|
| 141 |
-
[S(M.
|
|
|
|
|
|
|
| 142 |
[S(END)-.2,{...rest,ry:6,rx:3}],
|
| 143 |
[S(END)+.7,away],
|
| 144 |
];
|
|
@@ -164,8 +210,26 @@ async function seek(t){
|
|
| 164 |
// the opening logo, then the headlines, each on its stretch
|
| 165 |
const S=x=>INTRO+x;
|
| 166 |
const caps=[["c1",0,S(M.score.t)+.2],["c2",S(M.score.t)+.2,S(M.tap.t)+.6],["c3",S(M.tap.t)+.6,S(M.retry.t)+.4],
|
| 167 |
-
["c4",S(M.retry.t)+.4,S(M.great.t)+1.5],["c5",S(M.great.t)+1.5,S(M.
|
|
|
|
| 168 |
for(const [id,a,b] of caps){ const o=win(t,a,b); $(id).style.opacity=o; $(id).style.transform=`translateY(${(1-o)*30}px)`; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
// the ring on the red letter: after the camera has moved in, until the tip opens
|
| 170 |
const r=win(t,S(M.score.t)+.9,S(M.tap.t)+.25,.25);
|
| 171 |
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;
|
|
|
|
| 57 |
.chip.hot{background:var(--red)} .chip.hot b,.chip.hot span{color:#fff}
|
| 58 |
#outro img{width:560px}
|
| 59 |
#outro .cta{font:400 54px/1 "Secular One","Assistant",sans-serif;color:#fff;background:var(--navy);padding:26px 56px;border-radius:999px}
|
| 60 |
+
/* driving mode: the phone in a car's mount, drawn flat in the logo's colors */
|
| 61 |
+
#car{position:absolute;inset:0;opacity:0}
|
| 62 |
+
#shield{position:absolute;left:-20px;right:-20px;top:330px;height:760px;border-radius:120px 120px 0 0;overflow:hidden;
|
| 63 |
+
box-shadow:inset 0 0 0 34px #1B1D2E}
|
| 64 |
+
#shield svg{display:block}
|
| 65 |
+
#dash{position:absolute;left:-60px;right:-60px;top:1040px;bottom:-40px;border-radius:50% 50% 0 0/150px 150px 0 0;
|
| 66 |
+
background:linear-gradient(180deg,#2B2E4A 0%,#1F2138 30%,#171829 100%);box-shadow:0 -6px 0 #3A3E62 inset}
|
| 67 |
+
#dash .vent{position:absolute;top:120px;width:170px;height:46px;border-radius:23px;background:repeating-linear-gradient(90deg,#111222 0 14px,#25283F 14px 22px)}
|
| 68 |
+
#mount{position:absolute;left:676px;top:1170px;width:48px;height:240px;border-radius:24px;background:#3D405C;opacity:0}
|
| 69 |
+
#wheel{position:absolute;left:0;top:0;width:1080px;height:1920px;pointer-events:none;opacity:0}
|
| 70 |
+
.bub{position:absolute;padding:26px 40px;border-radius:44px;font:700 46px/1.25 "Assistant",sans-serif;opacity:0;
|
| 71 |
+
box-shadow:0 14px 34px rgba(0,0,0,.25);white-space:nowrap;direction:rtl}
|
| 72 |
+
.bub.app{background:#fff;color:var(--navy)} .bub.me{background:var(--teal);color:#fff}
|
| 73 |
+
.bub.ok{background:#2F7A4A;color:#fff}
|
| 74 |
+
.bub .ar{font:700 58px/1.2 "Noto Naskh Arabic","Amiri",serif}
|
| 75 |
+
.bub::after{content:"";position:absolute;width:44px;height:44px;background:inherit;transform:rotate(45deg);border-radius:8px;z-index:-1}
|
| 76 |
+
.bub.app::after,.bub.ok::after{right:-14px;top:50%;margin-top:-22px}
|
| 77 |
+
.bub.me::after{left:60px;bottom:-14px}
|
| 78 |
+
.wave{position:absolute;border:6px solid rgba(255,255,255,.9);border-radius:50%;opacity:0}
|
| 79 |
</style>
|
| 80 |
</head>
|
| 81 |
<body>
|
| 82 |
<div id="bg"></div>
|
| 83 |
|
| 84 |
+
<div id="car">
|
| 85 |
+
<div id="shield"><svg width="1120" height="760" viewBox="0 0 1120 760">
|
| 86 |
+
<defs><linearGradient id="sky" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#9FD6D2"/><stop offset=".55" stop-color="#F6E3C4"/><stop offset="1" stop-color="#F3C99A"/></linearGradient></defs>
|
| 87 |
+
<rect width="1120" height="760" fill="url(#sky)"/>
|
| 88 |
+
<circle cx="820" cy="300" r="74" fill="#F2A33A" opacity=".9"/>
|
| 89 |
+
<path d="M0 360 Q160 250 330 330 T700 320 T1120 300 V420 H0Z" fill="#6E8F7A"/>
|
| 90 |
+
<path d="M0 400 Q220 330 460 390 T1120 370 V440 H0Z" fill="#4F6E5E"/>
|
| 91 |
+
<path id="road" d="M520 400 H600 L1120 760 H0 Z" fill="#4A4F5C"/>
|
| 92 |
+
<path d="M520 400 L0 760 M600 400 L1120 760" stroke="#E8E2D0" stroke-width="8" fill="none"/>
|
| 93 |
+
<g id="dashes"></g>
|
| 94 |
+
</svg></div>
|
| 95 |
+
<div id="dash"><div class="vent" style="right:120px"></div><div class="vent" style="right:330px"></div></div>
|
| 96 |
+
<div id="mount"></div>
|
| 97 |
+
</div>
|
| 98 |
<div id="band"></div>
|
| 99 |
<div class="cap" id="c1"><h1>ืืืืจืื ืืงืื</h1><p>ืจืืืื ืคืืขื ืืขืืจืืช,<br>ืืืืืจืื ืืืชื <b>ืืขืจืืืช ืืืืืจืช</b></p></div>
|
| 100 |
<div class="cap" id="c2"><h1>ืืืคืืืงืฆืื ืืงืฉืืื<br>ื<em>ืื ืขืืฆืืจ</em></h1><p>ืืืกืื ืช ืืืืื ืืช ืื<br>ืฉืื ื ืฉืืข ื ืืื</p></div>
|
| 101 |
<div class="cap" id="c3"><h1>ืืณ ืืืงืื <em>ื</em>?</h1><p>ืืืค ืฉื ืืชื <b>ืืืืืจื ืขืืจืืช</b>:<br>ืื ื ืฉืืข, ืืืื ืืชืงื ืื</p></div>
|
| 102 |
<div class="cap" id="c4"><h1>ืื ืกืื ืฉืื โ <em style="color:var(--teal)">100!</em></h1><p>ื ื ืงืืื, <b>ืืืืืง ืืื ืฉืฆืจืื</b></p></div>
|
| 103 |
<div class="cap" id="c5"><h1>ืื ืืฉืคื โ <em style="color:var(--orange)">ืืจืื</em> ๐ฟ</h1><p>ืึพ75 ืืืขืื ืืื ืืืืื โ
<br>ืืืืกืคืื ืืืชื ืืืืจื ืื ืืืจื</p></div>
|
| 104 |
+
<div class="cap" id="c7"><h1>ืืฆื ื ืกืืขื ๐</h1><p>ืืชืจืืืื <b>ืืื ืืืืื</b>: ืืืคืืืงืฆืื ืืงืจืืื,<br>ืืงืฉืืื ืืืืืจืช ืืช ืืฆืืื ืืงืื</p></div>
|
| 105 |
<div class="cap" id="c6"><h1>ืืืฉืืงืื: <em style="color:var(--teal)">ืืคืืขื ืืืจืื</em></h1><p>ืืืคืืื ืืจืืืื ืืืืฆืืื ืืืืืช<br>ืฉื ืคืขืืื ืืขืืจืืช ืืืขืจืืืช</p></div>
|
| 106 |
|
| 107 |
<div id="stage">
|
|
|
|
| 114 |
</div>
|
| 115 |
</div>
|
| 116 |
<div id="ring"></div>
|
| 117 |
+
<svg id="wheel" viewBox="0 0 1080 1920"><g id="wheelG">
|
| 118 |
+
<circle cx="170" cy="1930" r="440" fill="none" stroke="#14151F" stroke-width="84"/>
|
| 119 |
+
<circle cx="170" cy="1930" r="440" fill="none" stroke="#2C2F45" stroke-width="10" stroke-dasharray="40 26" opacity=".7"/>
|
| 120 |
+
<path d="M170 1930 L560 1760" stroke="#14151F" stroke-width="70" stroke-linecap="round"/>
|
| 121 |
+
</g></svg>
|
| 122 |
+
<div class="bub app" id="b1">๐ ืืืืจ โ ืขืืจ ืืขืชืื</div>
|
| 123 |
+
<div class="bub me" id="b2">๐๏ธ <span class="ar">ุญููููุ ููุญูููู</span></div>
|
| 124 |
+
<div class="bub ok" id="b3">๐ ืืฆืืื! ยท 100 โ</div>
|
| 125 |
+
<div class="wave" id="w1"></div><div class="wave" id="w2"></div>
|
| 126 |
|
| 127 |
<div id="outro">
|
| 128 |
<h2>ืืขืจืืืช ืฉื ืืืืจื ืขืืจืืช<br>ื ืฉืืขืช ืืืจืช</h2>
|
|
|
|
| 165 |
LOGO_ANIMS=$("logoSvg").getAnimations({subtree:true}); LOGO_ANIMS.forEach(a=>a.pause());
|
| 166 |
const S=t=>INTRO+t;
|
| 167 |
const rest={tx:0,ty:0,k:1,rx:4,ry:-16,rz:0}, away={tx:0,ty:1500,k:.9,rx:20,ry:-30,rz:-6};
|
| 168 |
+
const MOUNT={tx:700-PHONE.cx, ty:1290-PHONE.cy, k:.6, rx:0, ry:-6, rz:-3}; // in the car's mount, on the dashboard
|
| 169 |
const [lu,lv]=center(M.letter.box), [tu,tv]=M.tip&&M.tip.box&&M.tip.box[3]>0?center(M.tip.box):[.5,.85];
|
| 170 |
const [gu,gv]=center(M.great.box);
|
| 171 |
KEYS=[
|
|
|
|
| 181 |
[S(M.great.t)+.8,focus(gu,gv,1.3,540,1250,{ry:4,rx:2})],
|
| 182 |
[S(M.great.t)+1.6,focus(gu,gv,1.3,540,1250,{ry:2,rx:2})],
|
| 183 |
[S(M.great.t)+2.4,focus(.5,.3,1.18,540,1150,{ry:-3,rx:2})],
|
| 184 |
+
[S(M.drive.t)-.6,focus(.5,.3,1.18,540,1150,{ry:-4,rx:2})],
|
| 185 |
+
[S(M.drive.t)+.5,MOUNT],
|
| 186 |
+
[S(M.driveEnd.t)-.1,{...MOUNT,ry:-2}],
|
| 187 |
+
[S(M.memory.t)+.8,{...rest,ry:12,rx:3}],
|
| 188 |
[S(END)-.2,{...rest,ry:6,rx:3}],
|
| 189 |
[S(END)+.7,away],
|
| 190 |
];
|
|
|
|
| 210 |
// the opening logo, then the headlines, each on its stretch
|
| 211 |
const S=x=>INTRO+x;
|
| 212 |
const caps=[["c1",0,S(M.score.t)+.2],["c2",S(M.score.t)+.2,S(M.tap.t)+.6],["c3",S(M.tap.t)+.6,S(M.retry.t)+.4],
|
| 213 |
+
["c4",S(M.retry.t)+.4,S(M.great.t)+1.5],["c5",S(M.great.t)+1.5,S(M.drive.t)-.2],
|
| 214 |
+
["c7",S(M.drive.t)-.2,S(M.driveEnd.t)+.2],["c6",S(M.memory.t)+.2,S(END)+.3]];
|
| 215 |
for(const [id,a,b] of caps){ const o=win(t,a,b); $(id).style.opacity=o; $(id).style.transform=`translateY(${(1-o)*30}px)`; }
|
| 216 |
+
// the car: in while the phone flies to the mount, out as it flies back
|
| 217 |
+
const car=win(t,S(M.drive.t)-.5,S(M.driveEnd.t)+.5,.7);
|
| 218 |
+
$("car").style.opacity=car; $("mount").style.opacity=ease((car-.6)/.4);
|
| 219 |
+
$("dash").style.transform=`translateY(${(1-car)*600}px)`;
|
| 220 |
+
$("wheel").style.opacity=car; $("wheel").style.transform=`translate(${-(1-car)*500}px,${(1-car)*600}px)`;
|
| 221 |
+
$("shadow").style.opacity=Math.max(0,1-Math.max(0,p.ty)/600)*(1-car);
|
| 222 |
+
let dashes=""; const speed=1.6, N=7; // lane dashes rushing at the viewer
|
| 223 |
+
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;
|
| 224 |
+
dashes+=`<rect x="${560-w/2}" y="${y}" width="${w}" height="${h}" fill="#F4EFE2" opacity="${.4+.6*d}"/>`; }
|
| 225 |
+
$("dashes").innerHTML=car>0?dashes:"";
|
| 226 |
+
// the conversation, in bubbles: the app reads out the verb, the driver answers, the app says the verdict
|
| 227 |
+
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]];
|
| 228 |
+
for(const [id,a,b,x,y] of bubs){ const o=win(t,a,b,.25);
|
| 229 |
+
Object.assign($(id).style,{left:x+"px",top:y+"px",opacity:o,transform:`scale(${lerp(.7,1,o)})`,transformOrigin:id==="b2"?"20% 100%":"100% 50%"}); }
|
| 230 |
+
const talk=win(t,S(M.turn.t)+.3,S(M.verdict.t),.2); // sound waves while the driver speaks
|
| 231 |
+
["w1","w2"].forEach((id,i)=>{ const ph=((t*1.4+i*.5)%1), r=60+ph*120;
|
| 232 |
+
Object.assign($(id).style,{left:150-r+"px",top:1440-r+"px",width:2*r+"px",height:2*r+"px",opacity:talk*(1-ph)}); });
|
| 233 |
// the ring on the red letter: after the camera has moved in, until the tip opens
|
| 234 |
const r=win(t,S(M.score.t)+.9,S(M.tap.t)+.25,.25);
|
| 235 |
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
|
@@ -118,19 +118,29 @@ async def record_screen(url, takes, work):
|
|
| 118 |
print(" retry:", await pg.inner_text("#score"))
|
| 119 |
await wait(5200) # the 100, then the beads headline over the gold bead
|
| 120 |
|
| 121 |
-
# driving mode on the same verb: the app reads it out, says "ืชืืจืื"
|
|
|
|
| 122 |
pause()
|
| 123 |
await pg.evaluate("store.set('ihki.driveSeen',true)") # past the first-time explanation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
await pg.click("#driveBtn"); await wait(150)
|
| 125 |
resume()
|
| 126 |
-
await mark("drive"
|
|
|
|
|
|
|
| 127 |
status = lambda: pg.inner_text("#status")
|
| 128 |
-
for _ in range(
|
| 129 |
if (await status()).startswith("ืชืืจืื"):
|
| 130 |
break
|
| 131 |
await wait(50)
|
|
|
|
| 132 |
await mark("turn")
|
| 133 |
-
await wait(
|
| 134 |
await pg.evaluate("t=>__demo.play(t)", "c876")
|
| 135 |
for _ in range(400):
|
| 136 |
if (await status()).startswith("ืฆืืื"):
|
|
@@ -138,7 +148,7 @@ async def record_screen(url, takes, work):
|
|
| 138 |
await wait(50)
|
| 139 |
await mark("verdict")
|
| 140 |
print(" driving:", await status())
|
| 141 |
-
await wait(
|
| 142 |
await mark("driveEnd")
|
| 143 |
pause()
|
| 144 |
await pg.evaluate("stopDrive()")
|
|
|
|
| 118 |
print(" retry:", await pg.inner_text("#score"))
|
| 119 |
await wait(5200) # the 100, then the beads headline over the gold bead
|
| 120 |
|
| 121 |
+
# driving mode on the same verb, fresh: the app reads it out (most of that wait is skipped), says "ืชืืจืื"
|
| 122 |
+
# and listens, then says the verdict
|
| 123 |
pause()
|
| 124 |
await pg.evaluate("store.set('ihki.driveSeen',true)") # past the first-time explanation
|
| 125 |
+
await pg.evaluate("pickCat('l4-verbs')"); await wait(600)
|
| 126 |
+
for _ in range(8):
|
| 127 |
+
if (await pg.inner_text("#prompt")).strip().startswith("ืืืืจ"):
|
| 128 |
+
break
|
| 129 |
+
await pg.click("#nextBtn"); await wait(300)
|
| 130 |
+
await pg.evaluate("scrollTo(0,0)")
|
| 131 |
await pg.click("#driveBtn"); await wait(150)
|
| 132 |
resume()
|
| 133 |
+
await mark("drive")
|
| 134 |
+
await wait(1800)
|
| 135 |
+
pause()
|
| 136 |
status = lambda: pg.inner_text("#status")
|
| 137 |
+
for _ in range(300):
|
| 138 |
if (await status()).startswith("ืชืืจืื"):
|
| 139 |
break
|
| 140 |
await wait(50)
|
| 141 |
+
resume()
|
| 142 |
await mark("turn")
|
| 143 |
+
await wait(400)
|
| 144 |
await pg.evaluate("t=>__demo.play(t)", "c876")
|
| 145 |
for _ in range(400):
|
| 146 |
if (await status()).startswith("ืฆืืื"):
|
|
|
|
| 148 |
await wait(50)
|
| 149 |
await mark("verdict")
|
| 150 |
print(" driving:", await status())
|
| 151 |
+
await wait(1500)
|
| 152 |
await mark("driveEnd")
|
| 153 |
pause()
|
| 154 |
await pg.evaluate("stopDrive()")
|
scripts/minerva_benchmark.py
CHANGED
|
@@ -68,12 +68,12 @@ def phones(result: dict) -> list[dict]:
|
|
| 68 |
|
| 69 |
def cached_refs(scorer, tts):
|
| 70 |
"""phrase -> [Reference] from the TTS cache only; never calls the provider."""
|
| 71 |
-
from app.arabic_diacritics import
|
| 72 |
from app.tts import RAEF_VOICE_ID
|
| 73 |
memo = {}
|
| 74 |
|
| 75 |
def get(phrase: dict) -> list:
|
| 76 |
-
text =
|
| 77 |
if text not in memo:
|
| 78 |
memo[text] = []
|
| 79 |
if tts is not None and tts.cached(text, RAEF_VOICE_ID):
|
|
|
|
| 68 |
|
| 69 |
def cached_refs(scorer, tts):
|
| 70 |
"""phrase -> [Reference] from the TTS cache only; never calls the provider."""
|
| 71 |
+
from app.arabic_diacritics import tts_arabic
|
| 72 |
from app.tts import RAEF_VOICE_ID
|
| 73 |
memo = {}
|
| 74 |
|
| 75 |
def get(phrase: dict) -> list:
|
| 76 |
+
text = tts_arabic(*phrase["variants"][0])
|
| 77 |
if text not in memo:
|
| 78 |
memo[text] = []
|
| 79 |
if tts is not None and tts.cached(text, RAEF_VOICE_ID):
|
tests/test_arabic_diacritics.py
CHANGED
|
@@ -168,3 +168,15 @@ def test_an_endless_audio_stream_is_abandoned_and_retryable(tmp_path, monkeypatc
|
|
| 168 |
with pytest.raises(TTSUnavailable) as e:
|
| 169 |
tts.wav("ู
ุฑุญุจุง")
|
| 170 |
assert e.value.transient and not list(tmp_path.rglob("*.wav"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
with pytest.raises(TTSUnavailable) as e:
|
| 169 |
tts.wav("ู
ุฑุญุจุง")
|
| 170 |
assert e.value.transient and not list(tmp_path.rglob("*.wav"))
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_the_voice_gets_huwwe_and_hiyye_respelled_but_the_page_does_not():
|
| 174 |
+
from app.arabic_diacritics import point_arabic, tts_arabic
|
| 175 |
+
|
| 176 |
+
ar, ipa = "ููู ุงูู ูููู ุงููุช", "h u w w e | ส a k a l | u h i j j e | ส a k l a t"
|
| 177 |
+
shown, said = point_arabic(ar, ipa), tts_arabic(ar, ipa)
|
| 178 |
+
assert said.split()[0] == "ูููุฉ" and shown.split()[0] != "ูููุฉ"
|
| 179 |
+
assert said.split()[1:] == shown.split()[1:2] + ["ูููููููู"] + shown.split()[3:] # "and she" too
|
| 180 |
+
assert tts_arabic("ูููุ", "h u w w e") == "ูููุฉุ"
|
| 181 |
+
assert tts_arabic("ููู", "h i j j e") == "ูููููู"
|
| 182 |
+
assert tts_arabic("ููู", "h u") == point_arabic("ููู", "h u") # another reading: untouched
|