Update MAX_SECONDS for recording duration and enhance voicebox options in UI
Browse files- app/scorer.py +1 -1
- static/index.html +30 -21
app/scorer.py
CHANGED
|
@@ -20,7 +20,7 @@ HEBREW_SOUNDS = set(_HEB_C) | set(_HEB_V)
|
|
| 20 |
log = logging.getLogger("ihki.scorer")
|
| 21 |
|
| 22 |
SAMPLE_RATE = 16_000
|
| 23 |
-
MAX_SECONDS =
|
| 24 |
|
| 25 |
# Fallbacks when the model vocab lacks a phoneme. The first symbol found in the vocab wins.
|
| 26 |
FALLBACKS = {
|
|
|
|
| 20 |
log = logging.getLogger("ihki.scorer")
|
| 21 |
|
| 22 |
SAMPLE_RATE = 16_000
|
| 23 |
+
MAX_SECONDS = 32 # the recording includes the time to read the sentence and think (the page stops at 30 s)
|
| 24 |
|
| 25 |
# Fallbacks when the model vocab lacks a phoneme. The first symbol found in the vocab wins.
|
| 26 |
FALLBACKS = {
|
static/index.html
CHANGED
|
@@ -156,7 +156,7 @@ body.driving .mic{width:84px;height:84px}
|
|
| 156 |
</div>
|
| 157 |
</main>
|
| 158 |
|
| 159 |
-
<div class="voicebox"><label>קול ההשמעה<select id="voiceId"></select></label><span class="sep"></span><label><input type="checkbox" id="readHe">הקראת המשפט בעברית</label></div>
|
| 160 |
<p class="note">הניתוח נעשה ברמת ההגה. ההשמעה נוצרת בקול של ערבית מדוברת מ-Sawtak Arabi; אם השירות אינו זמין, נעשה שימוש בקול הערבי של המכשיר.</p>
|
| 161 |
</div>
|
| 162 |
<div class="version" title="גרסת debug">v __VERSION__<span id="heInfo"></span><span id="ttsInfo"></span></div>
|
|
@@ -202,7 +202,7 @@ let ordered=false;
|
|
| 202 |
function startCat(){
|
| 203 |
const list=PHRASES.filter(p=>p.cat===cat&&p.listed!==false);
|
| 204 |
ordered=list.length>0&&list.every(p=>p.seq!=null);
|
| 205 |
-
order=(ordered?list.sort((a,b)=>a.seq-b.seq):shuffle(list)).map(p=>p.id); pos=0; show();
|
| 206 |
}
|
| 207 |
// Grammar-built phrases (grp set) exist for every pronoun, positive and negative: the two buttons move between them.
|
| 208 |
function variation(kind){
|
|
@@ -239,11 +239,12 @@ async function readPrompt(){ // resolves once the prompt has been read out, or
|
|
| 239 |
a.play().catch(()=>{ hePending=id; });
|
| 240 |
await over;
|
| 241 |
}
|
| 242 |
-
/*
|
| 243 |
-
the student only speaks.
|
| 244 |
-
|
|
|
|
| 245 |
function autoRecord(){
|
| 246 |
-
if(!
|
| 247 |
startRec(true);
|
| 248 |
}
|
| 249 |
function recordWhenReady(){
|
|
@@ -265,8 +266,9 @@ function startAutoNext(){
|
|
| 265 |
const tick=()=>{ if(left>0) $("nextBtn").textContent=`${NEXT_LABEL} (${left--}) ←`; else nextPhrase(); };
|
| 266 |
tick(); autoNext=setInterval(tick,1000);
|
| 267 |
}
|
| 268 |
-
function show(p){
|
| 269 |
cancelAutoNext();
|
|
|
|
| 270 |
cur=p||PHRASES[order[pos]];
|
| 271 |
promptRead=readPrompt();
|
| 272 |
$("negBtn").hidden=$("conjBtn").hidden=cur.grp==null;
|
|
@@ -281,7 +283,7 @@ function show(p){
|
|
| 281 |
if(ordered){ const idx=document.createElement("span"); idx.className="idx"; idx.textContent=`משפט ${pos+1} מתוך ${order.length}`; $("counter").prepend(idx," "); }
|
| 282 |
$("progress").hidden=!ordered; $("progressBar").style.width=`${(pos+1)/order.length*100}%`;
|
| 283 |
$("hint").hidden=true; $("hintAr").hidden=true; $("result").hidden=true;
|
| 284 |
-
$("status").textContent="ההקלטה מתחילה ונעצרת לבד";
|
| 285 |
$("playMine").disabled=true; $("prevBtn").disabled=pos===0;
|
| 286 |
// a recording of the sentence that was just left is dropped; the new one starts when it has stopped
|
| 287 |
if(!driving&&rec&&rec.state==="recording"){ recCancel=recRestart=true; rec.stop(); }
|
|
@@ -350,8 +352,10 @@ const finishRec=out=>{ const f=recDone; recDone=null; if(f) f(out); };
|
|
| 350 |
|
| 351 |
/* Voice activity detection: stops the recording by itself once the speaker has gone quiet.
|
| 352 |
Energy based: the level must rise clearly above the room noise measured in the first frames. */
|
| 353 |
-
|
| 354 |
-
|
|
|
|
|
|
|
| 355 |
const AC=window.AudioContext||window.webkitAudioContext; if(!AC) return null; // no Web Audio: manual stop only
|
| 356 |
const ctx=new AC(), an=ctx.createAnalyser(); an.fftSize=1024;
|
| 357 |
ctx.createMediaStreamSource(stream).connect(an);
|
|
@@ -366,7 +370,7 @@ function startVad(stream, onEnd){
|
|
| 366 |
if(isSpeech){ quiet=0; if(++loud>=VAD.startFrames) spoke=true; }
|
| 367 |
else{ loud=0; if(spoke) quiet+=VAD.frame; }
|
| 368 |
if(spoke && quiet>=VAD.endSilence) onEnd(true);
|
| 369 |
-
else if(!spoke && t>=
|
| 370 |
},VAD.frame);
|
| 371 |
return {stop(){ clearInterval(timer); ctx.close().catch(()=>{}); }};
|
| 372 |
}
|
|
@@ -397,8 +401,9 @@ async function startRec(auto){ // auto: started by the page for the sentence o
|
|
| 397 |
rec.onstart=()=>{
|
| 398 |
starting=false;
|
| 399 |
mic.classList.add("live"); $("result").hidden=true;
|
| 400 |
-
|
| 401 |
-
|
|
|
|
| 402 |
$("status").textContent=vad?"מקליט… דברו, ההקלטה תיעצר לבד (או לחצו לסיום)":"מקליט… לחצו שוב לסיום";
|
| 403 |
};
|
| 404 |
rec.onstop=async()=>{
|
|
@@ -440,9 +445,14 @@ const ttsUrls={}; // "phrase id|voice" -> {url, voice label} of the server-gen
|
|
| 440 |
$("readHe").checked=store.get("ihki.readHe",false);
|
| 441 |
$("readHe").onchange=()=>{
|
| 442 |
store.set("ihki.readHe",$("readHe").checked);
|
| 443 |
-
if(!(rec&&rec.state==="recording"))
|
| 444 |
};
|
| 445 |
$("voiceId").onchange=()=>store.set("ihki.voice",$("voiceId").value); // Sawtak voice of the playback; options come from the server
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
let ttsVoice=""; // debug: why the server voice was not used, shown in the version badge
|
| 447 |
const ttsInfo=t=>{ $("ttsInfo").textContent=" · 🔊 "+t; };
|
| 448 |
let ttsOff=false; // server has no TTS key: stop asking
|
|
@@ -468,11 +478,11 @@ async function answerAudio(id){ // {url,label} of the Sawtak answer, or null w
|
|
| 468 |
$("speakBtn").onclick=async()=>{
|
| 469 |
const id=cur.id, btn=$("speakBtn");
|
| 470 |
btn.disabled=true; const a=await answerAudio(id); btn.disabled=false;
|
| 471 |
-
if(a){ if(cur.id===id){
|
| 472 |
if(!("speechSynthesis" in window)){ ttsInfo("no voice available"); return; }
|
| 473 |
const u=new SpeechSynthesisUtterance(cur.ar); const v=arVoice();
|
| 474 |
if(v){u.voice=v;u.lang=v.lang;} else u.lang="ar-SA";
|
| 475 |
-
u.rate=.85; speechSynthesis.cancel(); speechSynthesis.speak(u);
|
| 476 |
ttsInfo("device voice"+(v?" "+v.name:"")+(ttsOff?" (tts not configured)":ttsVoice?" ("+ttsVoice+")":""));
|
| 477 |
};
|
| 478 |
|
|
@@ -482,16 +492,16 @@ $("speakBtn").onclick=async()=>{
|
|
| 482 |
three attempts at most. */
|
| 483 |
let driveRun=0, driveAudio=null, driveAudioDone=null, wakeLock=null;
|
| 484 |
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
| 485 |
-
function playUrl(url){
|
| 486 |
return new Promise(res=>{
|
| 487 |
-
const a=new Audio(url); driveAudio=a; driveAudioDone=res;
|
| 488 |
a.onended=a.onerror=()=>res(); a.play().catch(()=>res());
|
| 489 |
});
|
| 490 |
}
|
| 491 |
function speakLocal(text,lang){ // device voice, when the server could not speak
|
| 492 |
return new Promise(res=>{
|
| 493 |
if(!("speechSynthesis" in window)) return res();
|
| 494 |
-
const u=new SpeechSynthesisUtterance(text); u.lang=lang; u.onend=u.onerror=()=>res();
|
| 495 |
speechSynthesis.cancel(); speechSynthesis.speak(u);
|
| 496 |
});
|
| 497 |
}
|
|
@@ -526,7 +536,7 @@ async function drive(){
|
|
| 526 |
}else{ // not there yet: the only feedback is the correct answer, in Arabic
|
| 527 |
st(`ציון ${out.r.score} · התשובה הנכונה`);
|
| 528 |
const a=await answerAudio(id); if(!on()) break;
|
| 529 |
-
if(a){ ttsInfo(a.label); await playUrl(a.url); } else await speakLocal(PHRASES[id].ar,"ar"); // device voice when Sawtak is unavailable
|
| 530 |
}
|
| 531 |
if(!on()) break;
|
| 532 |
await pause(700); if(!on()) break;
|
|
@@ -564,7 +574,6 @@ fetch("/api/phrases").then(r=>r.json()).then(d=>{
|
|
| 564 |
$("startBtn").onclick=()=>{
|
| 565 |
const splash=$("logo-splash");
|
| 566 |
$("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
|
| 567 |
-
started=true; recordWhenReady();
|
| 568 |
};
|
| 569 |
$("startBtn").focus({preventScroll:true});
|
| 570 |
</script>
|
|
|
|
| 156 |
</div>
|
| 157 |
</main>
|
| 158 |
|
| 159 |
+
<div class="voicebox"><label>קול ההשמעה<select id="voiceId"></select></label><label>מהירות<select id="speed"><option value="0.8">x0.8</option><option value="1">x1</option><option value="1.5">x1.5</option></select></label><span class="sep"></span><label><input type="checkbox" id="readHe">הקראת המשפט בעברית</label></div>
|
| 160 |
<p class="note">הניתוח נעשה ברמת ההגה. ההשמעה נוצרת בקול של ערבית מדוברת מ-Sawtak Arabi; אם השירות אינו זמין, נעשה שימוש בקול הערבי של המכשיר.</p>
|
| 161 |
</div>
|
| 162 |
<div class="version" title="גרסת debug">v __VERSION__<span id="heInfo"></span><span id="ttsInfo"></span></div>
|
|
|
|
| 202 |
function startCat(){
|
| 203 |
const list=PHRASES.filter(p=>p.cat===cat&&p.listed!==false);
|
| 204 |
ordered=list.length>0&&list.every(p=>p.seq!=null);
|
| 205 |
+
order=(ordered?list.sort((a,b)=>a.seq-b.seq):shuffle(list)).map(p=>p.id); pos=0; show(null,true);
|
| 206 |
}
|
| 207 |
// Grammar-built phrases (grp set) exist for every pronoun, positive and negative: the two buttons move between them.
|
| 208 |
function variation(kind){
|
|
|
|
| 239 |
a.play().catch(()=>{ hePending=id; });
|
| 240 |
await over;
|
| 241 |
}
|
| 242 |
+
/* Moving to another sentence starts the recording by itself, once the prompt has been read out (when that is on):
|
| 243 |
+
the student only speaks. A topic's first sentence waits for the microphone button, and so does the next attempt
|
| 244 |
+
after a grade that does not move on. */
|
| 245 |
+
let autoRec=false, promptRead=Promise.resolve(); // autoRec: the sentence on screen is recorded without a press
|
| 246 |
function autoRecord(){
|
| 247 |
+
if(!autoRec||driving||mic.disabled||starting||(rec&&rec.state==="recording")) return;
|
| 248 |
startRec(true);
|
| 249 |
}
|
| 250 |
function recordWhenReady(){
|
|
|
|
| 266 |
const tick=()=>{ if(left>0) $("nextBtn").textContent=`${NEXT_LABEL} (${left--}) ←`; else nextPhrase(); };
|
| 267 |
tick(); autoNext=setInterval(tick,1000);
|
| 268 |
}
|
| 269 |
+
function show(p,wait){ // wait: do not record until the microphone button is pressed
|
| 270 |
cancelAutoNext();
|
| 271 |
+
autoRec=!wait;
|
| 272 |
cur=p||PHRASES[order[pos]];
|
| 273 |
promptRead=readPrompt();
|
| 274 |
$("negBtn").hidden=$("conjBtn").hidden=cur.grp==null;
|
|
|
|
| 283 |
if(ordered){ const idx=document.createElement("span"); idx.className="idx"; idx.textContent=`משפט ${pos+1} מתוך ${order.length}`; $("counter").prepend(idx," "); }
|
| 284 |
$("progress").hidden=!ordered; $("progressBar").style.width=`${(pos+1)/order.length*100}%`;
|
| 285 |
$("hint").hidden=true; $("hintAr").hidden=true; $("result").hidden=true;
|
| 286 |
+
$("status").textContent=wait?"לחצו ודברו. ההקלטה נעצרת לבד כשמסיימים":"ההקלטה מתחילה ונעצרת לבד";
|
| 287 |
$("playMine").disabled=true; $("prevBtn").disabled=pos===0;
|
| 288 |
// a recording of the sentence that was just left is dropped; the new one starts when it has stopped
|
| 289 |
if(!driving&&rec&&rec.state==="recording"){ recCancel=recRestart=true; rec.stop(); }
|
|
|
|
| 352 |
|
| 353 |
/* Voice activity detection: stops the recording by itself once the speaker has gone quiet.
|
| 354 |
Energy based: the level must rise clearly above the room noise measured in the first frames. */
|
| 355 |
+
// noSpeech: how long to wait for the first word. A recording that started by itself (noSpeechAuto) waits longer:
|
| 356 |
+
// the student first reads the new sentence. The server scores MAX_SECONDS at most (scorer.py).
|
| 357 |
+
const VAD={frame:50, startFrames:3, endSilence:1000, noSpeech:12000, noSpeechAuto:20000, maxSpeech:10000, minLevel:.012, ratio:3};
|
| 358 |
+
function startVad(stream, onEnd, noSpeech){
|
| 359 |
const AC=window.AudioContext||window.webkitAudioContext; if(!AC) return null; // no Web Audio: manual stop only
|
| 360 |
const ctx=new AC(), an=ctx.createAnalyser(); an.fftSize=1024;
|
| 361 |
ctx.createMediaStreamSource(stream).connect(an);
|
|
|
|
| 370 |
if(isSpeech){ quiet=0; if(++loud>=VAD.startFrames) spoke=true; }
|
| 371 |
else{ loud=0; if(spoke) quiet+=VAD.frame; }
|
| 372 |
if(spoke && quiet>=VAD.endSilence) onEnd(true);
|
| 373 |
+
else if(!spoke && t>=noSpeech) onEnd(false); // long enough to think of the answer
|
| 374 |
},VAD.frame);
|
| 375 |
return {stop(){ clearInterval(timer); ctx.close().catch(()=>{}); }};
|
| 376 |
}
|
|
|
|
| 401 |
rec.onstart=()=>{
|
| 402 |
starting=false;
|
| 403 |
mic.classList.add("live"); $("result").hidden=true;
|
| 404 |
+
const noSpeech=auto?VAD.noSpeechAuto:VAD.noSpeech;
|
| 405 |
+
stopTimer=setTimeout(()=>rec.state==="recording"&&rec.stop(),noSpeech+VAD.maxSpeech);
|
| 406 |
+
vad=startVad(stream, spoke=>{ heardSpeech=spoke; if(rec.state==="recording") rec.stop(); }, noSpeech);
|
| 407 |
$("status").textContent=vad?"מקליט… דברו, ההקלטה תיעצר לבד (או לחצו לסיום)":"מקליט… לחצו שוב לסיום";
|
| 408 |
};
|
| 409 |
rec.onstop=async()=>{
|
|
|
|
| 445 |
$("readHe").checked=store.get("ihki.readHe",false);
|
| 446 |
$("readHe").onchange=()=>{
|
| 447 |
store.set("ihki.readHe",$("readHe").checked);
|
| 448 |
+
if(!(rec&&rec.state==="recording")) promptRead=readPrompt();
|
| 449 |
};
|
| 450 |
$("voiceId").onchange=()=>store.set("ihki.voice",$("voiceId").value); // Sawtak voice of the playback; options come from the server
|
| 451 |
+
// Speed of the Arabic answer. The provider has no speed setting: the browser plays the same audio slower or faster, at the same pitch.
|
| 452 |
+
$("speed").value=String(store.get("ihki.speed",1)); if(!$("speed").value) $("speed").value="1";
|
| 453 |
+
$("speed").onchange=()=>store.set("ihki.speed",+$("speed").value);
|
| 454 |
+
const answerRate=()=>+$("speed").value||1;
|
| 455 |
+
function answerPlayer(url){ const a=new Audio(url); a.preservesPitch=true; a.playbackRate=answerRate(); return a; }
|
| 456 |
let ttsVoice=""; // debug: why the server voice was not used, shown in the version badge
|
| 457 |
const ttsInfo=t=>{ $("ttsInfo").textContent=" · 🔊 "+t; };
|
| 458 |
let ttsOff=false; // server has no TTS key: stop asking
|
|
|
|
| 478 |
$("speakBtn").onclick=async()=>{
|
| 479 |
const id=cur.id, btn=$("speakBtn");
|
| 480 |
btn.disabled=true; const a=await answerAudio(id); btn.disabled=false;
|
| 481 |
+
if(a){ if(cur.id===id){ answerPlayer(a.url).play(); ttsInfo(a.label); } return; }
|
| 482 |
if(!("speechSynthesis" in window)){ ttsInfo("no voice available"); return; }
|
| 483 |
const u=new SpeechSynthesisUtterance(cur.ar); const v=arVoice();
|
| 484 |
if(v){u.voice=v;u.lang=v.lang;} else u.lang="ar-SA";
|
| 485 |
+
u.rate=.85*answerRate(); speechSynthesis.cancel(); speechSynthesis.speak(u);
|
| 486 |
ttsInfo("device voice"+(v?" "+v.name:"")+(ttsOff?" (tts not configured)":ttsVoice?" ("+ttsVoice+")":""));
|
| 487 |
};
|
| 488 |
|
|
|
|
| 492 |
three attempts at most. */
|
| 493 |
let driveRun=0, driveAudio=null, driveAudioDone=null, wakeLock=null;
|
| 494 |
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
| 495 |
+
function playUrl(url,answer){ // answer: the Arabic answer, played at the chosen speed
|
| 496 |
return new Promise(res=>{
|
| 497 |
+
const a=answer?answerPlayer(url):new Audio(url); driveAudio=a; driveAudioDone=res;
|
| 498 |
a.onended=a.onerror=()=>res(); a.play().catch(()=>res());
|
| 499 |
});
|
| 500 |
}
|
| 501 |
function speakLocal(text,lang){ // device voice, when the server could not speak
|
| 502 |
return new Promise(res=>{
|
| 503 |
if(!("speechSynthesis" in window)) return res();
|
| 504 |
+
const u=new SpeechSynthesisUtterance(text); u.lang=lang; if(lang==="ar") u.rate=answerRate(); u.onend=u.onerror=()=>res();
|
| 505 |
speechSynthesis.cancel(); speechSynthesis.speak(u);
|
| 506 |
});
|
| 507 |
}
|
|
|
|
| 536 |
}else{ // not there yet: the only feedback is the correct answer, in Arabic
|
| 537 |
st(`ציון ${out.r.score} · התשובה הנכונה`);
|
| 538 |
const a=await answerAudio(id); if(!on()) break;
|
| 539 |
+
if(a){ ttsInfo(a.label); await playUrl(a.url,true); } else await speakLocal(PHRASES[id].ar,"ar"); // device voice when Sawtak is unavailable
|
| 540 |
}
|
| 541 |
if(!on()) break;
|
| 542 |
await pause(700); if(!on()) break;
|
|
|
|
| 574 |
$("startBtn").onclick=()=>{
|
| 575 |
const splash=$("logo-splash");
|
| 576 |
$("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
|
|
|
|
| 577 |
};
|
| 578 |
$("startBtn").focus({preventScroll:true});
|
| 579 |
</script>
|