3v324v23 commited on
Commit
6b773fd
ยท
1 Parent(s): 670a451

feat(tts): implement tts_arabic function for improved Arabic pronunciation handling and update references

Browse files
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(point_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,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 = point_arabic(*phrase["variants"][0]), []
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 = point_arabic(*phrase["variants"][0])
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 = point_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,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 = point_arabic(*phrase["variants"][0])
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 point_arabic
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 = point_arabic(*phrase["variants"][0])
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.memory.t)-.1,focus(.5,.3,1.18,540,1150,{ry:-4,rx:2})],
141
- [S(M.memory.t)+.5,{...rest,ry:12,rx:3}],
 
 
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.memory.t)],["c6",S(M.memory.t),S(END)+.3]];
 
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 "ืชื•ืจื›ื" and listens, then says the verdict
 
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", "#driveBtn")
 
 
127
  status = lambda: pg.inner_text("#status")
128
- for _ in range(200):
129
  if (await status()).startswith("ืชื•ืจื›ื"):
130
  break
131
  await wait(50)
 
132
  await mark("turn")
133
- await wait(500)
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(2300)
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 point_arabic
72
  from app.tts import RAEF_VOICE_ID
73
  memo = {}
74
 
75
  def get(phrase: dict) -> list:
76
- text = point_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):
 
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