3v324v23 commited on
Commit
969fda1
·
1 Parent(s): 0508bdf

Update MAX_SECONDS for recording duration and enhance voicebox options in UI

Browse files
Files changed (2) hide show
  1. app/scorer.py +1 -1
  2. 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 = 22 # driving mode records the thinking pause too (the page stops at 20 s)
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
- /* The recording starts by itself on every new sentence, once its prompt has been read out (when that is on):
243
- the student only speaks. After a grade that does not move on, the microphone button starts the next attempt. */
244
- let started=false, promptRead=Promise.resolve(); // started: the opening screen is gone
 
245
  function autoRecord(){
246
- if(!started||driving||mic.disabled||starting||(rec&&rec.state==="recording")) return;
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
- const VAD={frame:50, startFrames:3, endSilence:1000, noSpeech:12000, minLevel:.012, ratio:3};
354
- function startVad(stream, onEnd){
 
 
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>=VAD.noSpeech) onEnd(false); // long enough to think of the answer
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
- stopTimer=setTimeout(()=>rec.state==="recording"&&rec.stop(),20000);
401
- vad=startVad(stream, spoke=>{ heardSpeech=spoke; if(rec.state==="recording") rec.stop(); });
 
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")){ promptRead=readPrompt(); recordWhenReady(); }
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){ new Audio(a.url).play(); ttsInfo(a.label); } return; }
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>