3v324v23 commited on
Commit
786d97e
·
1 Parent(s): b8b95f7

Add story playback feature with voice integration and UI updates

Browse files
Files changed (2) hide show
  1. app/workbook.py +3 -2
  2. static/index.html +46 -6
app/workbook.py CHANGED
@@ -441,13 +441,14 @@ LESSONS = {
441
  }
442
  LESSON_TABS = [{"id": str(n), "name": f"שיעור {n}"} for n in LESSONS]
443
  # Topics the page lays out as a story: a conversation, all its lines on the card, said one after the other.
444
- STORIES = {"l2-talk"}
 
445
  # What the card calls a topic's items where they are not sentences ("פועל 1 מתוך 4"), by topic id in any lesson,
446
  # and what it asks for where the answer is two forms said together.
447
  UNITS = {"pron": "מילה", "verbs": "פועל", "dual": "מילה", "family": "מילה"}
448
  TASKS = {"verbs": "אמרו את הפועל בערבית מדוברת: בעבר ואז בעתיד",
449
  "dual": "אמרו בערבית מדוברת: ביחיד ואז בזוגי"}
450
- CATEGORIES = [{"id": f"l{n}-{tid}", "name": name, "lesson": str(n), **({"story": True} if f"l{n}-{tid}" in STORIES else {}),
451
  **({"unit": UNITS[tid]} if tid in UNITS else {}), **({"task": TASKS[tid]} if tid in TASKS else {})}
452
  for n, topics in LESSONS.items() for tid, name, *_ in topics]
453
 
 
441
  }
442
  LESSON_TABS = [{"id": str(n), "name": f"שיעור {n}"} for n in LESSONS]
443
  # Topics the page lays out as a story: a conversation, all its lines on the card, said one after the other.
444
+ # Each has its two speakers' voices, by name in the voice list: the odd lines are the first one's, the even the second's.
445
+ STORIES = {"l2-talk": ("אמיר", "ראאף")}
446
  # What the card calls a topic's items where they are not sentences ("פועל 1 מתוך 4"), by topic id in any lesson,
447
  # and what it asks for where the answer is two forms said together.
448
  UNITS = {"pron": "מילה", "verbs": "פועל", "dual": "מילה", "family": "מילה"}
449
  TASKS = {"verbs": "אמרו את הפועל בערבית מדוברת: בעבר ואז בעתיד",
450
  "dual": "אמרו בערבית מדוברת: ביחיד ואז בזוגי"}
451
+ CATEGORIES = [{"id": f"l{n}-{tid}", "name": name, "lesson": str(n), **({"story": True, "voices": list(STORIES[f"l{n}-{tid}"])} if f"l{n}-{tid}" in STORIES else {}),
452
  **({"unit": UNITS[tid]} if tid in UNITS else {}), **({"task": TASKS[tid]} if tid in TASKS else {})}
453
  for n, topics in LESSONS.items() for tid, name, *_ in topics]
454
 
static/index.html CHANGED
@@ -189,6 +189,7 @@ dialog::backdrop{background:rgba(0,0,0,.45)}
189
  .sline:not([aria-current]):active{background:var(--olive-soft)}
190
  .sline:not([aria-current]):hover .ic,.sline:not([aria-current]):active .ic{color:var(--olive)}
191
  .sline .tools .ic{width:16px;height:16px;margin-inline:13px;color:var(--muted)}
 
192
  .sline .mic{width:42px;height:42px}
193
  .sline .mic svg{width:20px;height:20px}
194
  .sline .tr.lit{color:var(--muted)}
@@ -241,6 +242,7 @@ body.driving .card.storymode .prompt{display:block}
241
  <button class="btn" id="homeBtn"><svg class="ic" aria-hidden="true"><use href="#i-back"/></svg> המסלול</button>
242
  <span class="sep"></span>
243
  <button class="btn" id="listBtn"><svg class="ic" aria-hidden="true"><use href="#i-list"/></svg> רשימת המשפטים</button>
 
244
  <button class="btn" id="setBtn" title="הגדרות השמעה" aria-label="הגדרות השמעה"><svg class="ic" aria-hidden="true"><use href="#i-set"/></svg></button>
245
  </div>
246
  <div id="envWarn" class="warn" hidden></div>
@@ -420,6 +422,7 @@ function pickCat(id){
420
  }
421
  function showPath(){ // back to the path: whatever the card was reading, playing or recording stops
422
  if(driving) stopDrive();
 
423
  cancelAutoNext(); storyRun++; autoRec=false; hePending=null; if(heAudio) heAudio.pause();
424
  if(driveAudio){ driveAudio.pause(); if(driveAudioDone) driveAudioDone(); driveAudio=null; }
425
  if("speechSynthesis" in window) speechSynthesis.cancel();
@@ -592,13 +595,42 @@ function drawStory(){
592
  row.append(tr);
593
  }
594
  if(now) row.setAttribute("aria-current","true");
595
- else{ row.onclick=()=>openSentence(id,true); row.title="לתרגל את המשפט הזה"; tools.insertAdjacentHTML("beforeend",'<svg class="ic" aria-hidden="true"><use href="#i-mic"/></svg>'); }
596
  box.appendChild(row);
597
  }
598
  seatMic();
599
  const at=box.querySelector('[aria-current="true"]'); // the marked line in the middle of the box, without moving the page
600
  if(at) box.scrollTop=at.offsetTop-(box.clientHeight-at.offsetHeight)/2;
601
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  async function storyRetry(){ // the grade did not pass: the answer is played, then the same line again, or the next one after the last try
603
  const id=cur.id, run=++storyRun, on=()=>!driving&&cur&&cur.id===id&&run===storyRun;
604
  const last=++storyTries>=STORY.tries;
@@ -611,7 +643,7 @@ async function storyRetry(){ // the grade did not pass: the answer is played,
611
  $("status").textContent="נסו שוב"; autoRec=true; autoRecord();
612
  }
613
  function show(p,wait){ // wait: do not record until the microphone button is pressed
614
- cancelAutoNext();
615
  storyRun++; // an answer being played for the line that is left is stopped, and its retry dropped
616
  if(!driving&&driveAudio){ driveAudio.pause(); if(driveAudioDone) driveAudioDone(); driveAudio=null; }
617
  autoRec=!wait;
@@ -619,7 +651,7 @@ function show(p,wait){ // wait: do not record until the microphone button is p
619
  if(topic.includes(cur.id)) here=cur.id;
620
  if(storyFor!==cur.id){ storyFor=cur.id; storyTries=0; }
621
  const story=isStory()&&topic.includes(cur.id);
622
- $("card").classList.toggle("storymode",story); $("story").hidden=!story; $("listBtn").hidden=story; // a story is its own list of sentences
623
  if(story) drawStory(); else seatMic();
624
  promptRead=readPrompt();
625
  $("varRow").hidden=cur.grp==null;
@@ -642,7 +674,7 @@ function show(p,wait){ // wait: do not record until the microphone button is p
642
  hintBox.className="donehint "+(b>=EXCELLENT?"good":b>=60?"mid":"bad"); hintBox.textContent="✓ כבר תרגלתם את המשפט הזה · הציון הטוב ביותר:"; hintBox.append(n);
643
  }
644
  $("hint").hidden=true; $("hintAr").hidden=true; $("result").hidden=true;
645
- $("status").textContent=wait?"לחצו ודברו. ההקלטה נעצרת לבד כשמסיימים":"ההקלטה מתחילה ונעצרת לבד";
646
  $("playMine").disabled=true; $("nextBtn").classList.remove("primary"); // the microphone is the one thing to press until there is a score
647
  // a recording of the sentence that was just left is dropped; the new one starts when it has stopped
648
  if(!driving&&rec&&rec.state==="recording"){ recCancel=recRestart=true; rec.stop(); }
@@ -751,7 +783,7 @@ if(!navigator.mediaDevices||!window.MediaRecorder){
751
  const w=$("envWarn"); w.textContent="הדפדפן הזה לא תומך בהקלטה. השתמשו ב־Chrome, Edge, Firefox או Safari עדכניים, בכתובת https."; w.hidden=false; mic.disabled=true;
752
  }
753
 
754
- mic.onclick=()=>startRec(false);
755
  async function startRec(auto){ // auto: started by the page for the sentence on screen, not by a press
756
  hePending=null; if(heAudio) heAudio.pause(); // never record the prompt being read out
757
  if(rec&&rec.state==="recording"){ rec.stop(); return; }
@@ -840,8 +872,15 @@ $("speed").onchange=()=>store.set("ihki.speed",+$("speed").value);
840
  const answerRate=()=>+$("speed").value||1;
841
  function answerPlayer(url){ const a=new Audio(url); a.preservesPitch=true; a.playbackRate=answerRate(); return a; }
842
  let ttsOff=false; // server has no TTS key: stop asking
 
 
 
 
 
 
 
843
  async function answerAudio(id){ // {url} of the Sawtak answer, or null when the server voice is unavailable
844
- const voice=$("voiceId").value, key=id+"|"+voice;
845
  if(!ttsOff && !ttsUrls[key]){
846
  try{
847
  const res=await api("/api/tts/"+id+(voice?"?voice="+encodeURIComponent(voice):""));
@@ -937,6 +976,7 @@ $("driveBtn").onclick=async()=>{
937
  if(driving){ stopDrive(); return; }
938
  if(!cur||mic.disabled) return;
939
  if(!store.get("ihki.driveSeen",false)){ $("driveDlg").showModal(); return; } // the first time: what the mode does, and then it starts
 
940
  if(rec&&rec.state==="recording"){ recCancel=true; rec.stop(); } // the recording that started by itself
941
  driving=true; document.body.classList.add("driving"); $("driveBtn").innerHTML=DRIVE_OFF; seatMic();
942
  hePending=null; if(heAudio) heAudio.pause();
 
189
  .sline:not([aria-current]):active{background:var(--olive-soft)}
190
  .sline:not([aria-current]):hover .ic,.sline:not([aria-current]):active .ic{color:var(--olive)}
191
  .sline .tools .ic{width:16px;height:16px;margin-inline:13px;color:var(--muted)}
192
+ .sline.playing{box-shadow:inset 0 0 0 2px var(--olive)}
193
  .sline .mic{width:42px;height:42px}
194
  .sline .mic svg{width:20px;height:20px}
195
  .sline .tr.lit{color:var(--muted)}
 
242
  <button class="btn" id="homeBtn"><svg class="ic" aria-hidden="true"><use href="#i-back"/></svg> המסלול</button>
243
  <span class="sep"></span>
244
  <button class="btn" id="listBtn"><svg class="ic" aria-hidden="true"><use href="#i-list"/></svg> רשימת המשפטים</button>
245
+ <button class="btn" id="hearBtn" title="השמעת כל השיחה בערבית, מההתחלה ועד הסוף" hidden><svg class="ic" aria-hidden="true"><use href="#i-play"/></svg> השמעת השיחה</button>
246
  <button class="btn" id="setBtn" title="הגדרות השמעה" aria-label="הגדרות השמעה"><svg class="ic" aria-hidden="true"><use href="#i-set"/></svg></button>
247
  </div>
248
  <div id="envWarn" class="warn" hidden></div>
 
422
  }
423
  function showPath(){ // back to the path: whatever the card was reading, playing or recording stops
424
  if(driving) stopDrive();
425
+ stopListen();
426
  cancelAutoNext(); storyRun++; autoRec=false; hePending=null; if(heAudio) heAudio.pause();
427
  if(driveAudio){ driveAudio.pause(); if(driveAudioDone) driveAudioDone(); driveAudio=null; }
428
  if("speechSynthesis" in window) speechSynthesis.cancel();
 
595
  row.append(tr);
596
  }
597
  if(now) row.setAttribute("aria-current","true");
598
+ else{ row.onclick=()=>openSentence(id); row.title="לתרגל את המשפט הזה"; tools.insertAdjacentHTML("beforeend",'<svg class="ic" aria-hidden="true"><use href="#i-mic"/></svg>'); }
599
  box.appendChild(row);
600
  }
601
  seatMic();
602
  const at=box.querySelector('[aria-current="true"]'); // the marked line in the middle of the box, without moving the page
603
  if(at) box.scrollTop=at.offsetTop-(box.clientHeight-at.offsetHeight)/2;
604
  }
605
+ /* A story can be heard whole: its Arabic line after line, from the first to the last, the line being played framed.
606
+ Another press stops it, and so does whatever opens a line, records or leaves the card. */
607
+ let listening=false;
608
+ const IDLE="לחצו ודברו. ההקלטה נעצרת לבד כשמסיימים";
609
+ const HEAR_ON=$("hearBtn").innerHTML, HEAR_OFF=HEAR_ON.replace("i-play","i-stop").replace("השמעת השיחה","עצירה");
610
+ function stopListen(){
611
+ if(!listening) return;
612
+ storyRun++; // the loop below sees it and ends
613
+ if(driveAudio){ driveAudio.pause(); if(driveAudioDone) driveAudioDone(); driveAudio=null; }
614
+ if("speechSynthesis" in window) speechSynthesis.cancel();
615
+ $("status").textContent=IDLE;
616
+ }
617
+ $("hearBtn").onclick=async()=>{
618
+ if(listening){ stopListen(); return; }
619
+ cancelAutoNext(); autoRec=false; hePending=null; if(heAudio) heAudio.pause();
620
+ if(rec&&rec.state==="recording"){ recCancel=true; recRestart=false; rec.stop(); } // the recording that started by itself
621
+ const run=++storyRun, on=()=>run===storyRun, box=$("story"), frame=row=>{ for(const r of box.querySelectorAll(".playing")) r.classList.remove("playing"); if(row) row.classList.add("playing"); };
622
+ listening=true; $("hearBtn").innerHTML=HEAR_OFF; $("status").textContent="הקשיבו לשיחה";
623
+ for(const [k,id] of topic.entries()){
624
+ const a=await answerAudio(id); if(!on()) break;
625
+ const row=box.children[k]; frame(row);
626
+ if(row) box.scrollTop=row.offsetTop-(box.clientHeight-row.offsetHeight)/2;
627
+ if(a) await playUrl(a.url,true); else await speakLocal(phraseOf(id).ar,"ar"); // device voice when Sawtak is unavailable
628
+ if(!on()) break;
629
+ await pause(350); if(!on()) break;
630
+ }
631
+ listening=false; $("hearBtn").innerHTML=HEAR_ON; frame(null);
632
+ if(on()) $("status").textContent=IDLE;
633
+ };
634
  async function storyRetry(){ // the grade did not pass: the answer is played, then the same line again, or the next one after the last try
635
  const id=cur.id, run=++storyRun, on=()=>!driving&&cur&&cur.id===id&&run===storyRun;
636
  const last=++storyTries>=STORY.tries;
 
643
  $("status").textContent="נסו שוב"; autoRec=true; autoRecord();
644
  }
645
  function show(p,wait){ // wait: do not record until the microphone button is pressed
646
+ cancelAutoNext(); stopListen();
647
  storyRun++; // an answer being played for the line that is left is stopped, and its retry dropped
648
  if(!driving&&driveAudio){ driveAudio.pause(); if(driveAudioDone) driveAudioDone(); driveAudio=null; }
649
  autoRec=!wait;
 
651
  if(topic.includes(cur.id)) here=cur.id;
652
  if(storyFor!==cur.id){ storyFor=cur.id; storyTries=0; }
653
  const story=isStory()&&topic.includes(cur.id);
654
+ $("card").classList.toggle("storymode",story); $("story").hidden=!story; $("listBtn").hidden=story; $("hearBtn").hidden=!story; // a story is its own list of sentences, and can be heard whole
655
  if(story) drawStory(); else seatMic();
656
  promptRead=readPrompt();
657
  $("varRow").hidden=cur.grp==null;
 
674
  hintBox.className="donehint "+(b>=EXCELLENT?"good":b>=60?"mid":"bad"); hintBox.textContent="✓ כבר תרגלתם את המשפט הזה · הציון הטוב ביותר:"; hintBox.append(n);
675
  }
676
  $("hint").hidden=true; $("hintAr").hidden=true; $("result").hidden=true;
677
+ $("status").textContent=wait?IDLE:"ההקלטה מתחילה ונעצרת לבד";
678
  $("playMine").disabled=true; $("nextBtn").classList.remove("primary"); // the microphone is the one thing to press until there is a score
679
  // a recording of the sentence that was just left is dropped; the new one starts when it has stopped
680
  if(!driving&&rec&&rec.state==="recording"){ recCancel=recRestart=true; rec.stop(); }
 
783
  const w=$("envWarn"); w.textContent="הדפדפן הזה לא תומך בהקלטה. השתמשו ב־Chrome, Edge, Firefox או Safari עדכניים, בכתובת https."; w.hidden=false; mic.disabled=true;
784
  }
785
 
786
+ mic.onclick=()=>{ stopListen(); startRec(false); };
787
  async function startRec(auto){ // auto: started by the page for the sentence on screen, not by a press
788
  hePending=null; if(heAudio) heAudio.pause(); // never record the prompt being read out
789
  if(rec&&rec.state==="recording"){ rec.stop(); return; }
 
872
  const answerRate=()=>+$("speed").value||1;
873
  function answerPlayer(url){ const a=new Audio(url); a.preservesPitch=true; a.playbackRate=answerRate(); return a; }
874
  let ttsOff=false; // server has no TTS key: stop asking
875
+ // A story is a dialogue: its lines are said in turn by the topic's two voices, found by name (without the points) in
876
+ // the server's list, so the speakers sound apart. Anywhere else, and when a name is not in the list, the chosen voice.
877
+ function lineVoice(id){
878
+ const sel=$("voiceId"), c=CATS.find(c=>c.id===cat)||{}, k=topic.indexOf(id);
879
+ const name=c.story&&c.voices&&k>=0&&c.voices[k%2], o=name&&[...sel.options].find(o=>o.text.replace(/[\u0591-\u05C7]/g,"")===name);
880
+ return o?o.value:sel.value;
881
+ }
882
  async function answerAudio(id){ // {url} of the Sawtak answer, or null when the server voice is unavailable
883
+ const voice=lineVoice(id), key=id+"|"+voice;
884
  if(!ttsOff && !ttsUrls[key]){
885
  try{
886
  const res=await api("/api/tts/"+id+(voice?"?voice="+encodeURIComponent(voice):""));
 
976
  if(driving){ stopDrive(); return; }
977
  if(!cur||mic.disabled) return;
978
  if(!store.get("ihki.driveSeen",false)){ $("driveDlg").showModal(); return; } // the first time: what the mode does, and then it starts
979
+ stopListen();
980
  if(rec&&rec.state==="recording"){ recCancel=true; rec.stop(); } // the recording that started by itself
981
  driving=true; document.body.classList.add("driving"); $("driveBtn").innerHTML=DRIVE_OFF; seatMic();
982
  hePending=null; if(heAudio) heAudio.pause();