Add story playback feature with voice integration and UI updates
Browse files- app/workbook.py +3 -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 |
-
|
|
|
|
| 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
|
| 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=
|
| 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();
|