teach/public/js/widgets/noise.js
Ramon 9920fd3855
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
feat: vier sociaal-emotionele widgets, waaronder een geluidsmeter (v0.4.21-beta)
- Geluidsmeter: meet het klasgeluid live via de microfoon in een verticale
  meter (rustig/pratend/te luid). Twee aanpasbare streefniveaus ("Rustig
  tot" / "Te luid vanaf") laten de leerkracht de meter afstemmen op het
  eigen lokaal en apparaat. Microfoon staat standaard uit en start alleen
  na een expliciete klik; er wordt niets opgenomen of verstuurd, alles
  blijft lokaal in de browser. Stopt de microfoonstream vanzelf zodra de
  widget van het bord verdwijnt, ook als "Stop" niet eerst is ingedrukt.
- Gevoelszones: individuele check-in met vier zones (blauw/groen/geel/
  rood, Zones of Regulation-stijl) - elke zone geeft een korte uitleg én
  een concrete tip om jezelf te helpen. Anders dan de bestaande
  Gevoelsmeter (mood.js), die een anonieme klaspoll is.
- Ademhalingsoefening: een groeiende/krimpende cirkel begeleidt rustig
  ademhalen in drie instelbare tempo's (4-4, 4-7-8, box breathing),
  met rondjesteller.
- Complimentenmachine: trekt een willekeurig compliment uit twintig
  kindvriendelijke complimenten, met dezelfde spin-en-onthul-animatie als
  de namenkiezer.
- Alle vier volledig tweetalig (nl/en), toegevoegd aan de mobiele-dekking-
  test, en geverifieerd in de browser inclusief een echte (fake) micro-
  foonstream voor de geluidsmeter.
2026-07-19 00:44:39 +02:00

124 lines
4.9 KiB
JavaScript

/* widget: Geluidsmeter */
/* =========================================================
Live noise meter — mic input stays local, never recorded or sent anywhere
==========================================================*/
function mountNoise(root, initState){
let zoneGreen = (initState && initState.zoneGreen) || 40;
let zoneYellow = (initState && initState.zoneYellow) || 70;
let running = false, audioCtx = null, analyser = null, stream = null, rafId = null, warnTimer = null;
let level = 0, peak = 0, timeData = null;
root.innerHTML = `
<div class="nm">
<p class="play-mission nm-mission"></p>
<div class="nm-stage">
<div class="nm-gauge">
<div class="nm-fill"></div>
<div class="nm-mark green"></div>
<div class="nm-mark yellow"></div>
</div>
<div class="nm-face"></div>
</div>
<output class="nm-status" aria-live="polite"></output>
<button class="tbtn nm-toggle"></button>
<div class="nm-zones">
<label class="nm-zone-row"><span class="nm-zone-lbl"></span><input type="range" class="nm-z1" min="10" max="90" step="5"></label>
<label class="nm-zone-row"><span class="nm-zone-lbl"></span><input type="range" class="nm-z2" min="10" max="95" step="5"></label>
</div>
<p class="nm-privacy"></p>
</div>`;
const gauge = root.querySelector(".nm-gauge"),
fill = root.querySelector(".nm-fill"),
markGreen = root.querySelector(".nm-mark.green"),
markYellow = root.querySelector(".nm-mark.yellow"),
face = root.querySelector(".nm-face"),
status = root.querySelector(".nm-status"),
toggle = root.querySelector(".nm-toggle"),
z1 = root.querySelector(".nm-z1"),
z2 = root.querySelector(".nm-z2");
z1.value = zoneGreen;
z2.value = zoneYellow;
function currentZone(){ return level < zoneGreen ? "green" : level < zoneYellow ? "yellow" : "red"; }
function draw(){
fill.style.height = Math.round(level) + "%";
fill.className = "nm-fill " + currentZone();
markGreen.style.bottom = zoneGreen + "%";
markYellow.style.bottom = zoneYellow + "%";
face.textContent = !running ? "🎙️" : currentZone() === "green" ? "🙂" : currentZone() === "yellow" ? "😐" : "😬";
status.textContent = !running ? T("noiseOff")
: T("noiseStatus_" + currentZone());
gauge.classList.toggle("warn", running && currentZone() === "red");
}
function loop(){
if(!running || !document.body.contains(root)){ stop(); return; }
analyser.getByteTimeDomainData(timeData);
let sum = 0;
for(let i = 0; i < timeData.length; i++){ const v = (timeData[i] - 128) / 128; sum += v * v; }
const rms = Math.sqrt(sum / timeData.length);
const pct = Math.min(100, rms * 100 * 4.2);
level = level * 0.75 + pct * 0.25;
peak = Math.max(peak * 0.995, level);
draw();
rafId = requestAnimationFrame(loop);
}
async function start(){
try{
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
}catch(e){
status.textContent = T("noiseDenied");
return;
}
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
if(audioCtx.state === "suspended") await audioCtx.resume().catch(()=>{});
const source = audioCtx.createMediaStreamSource(stream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
timeData = new Uint8Array(analyser.fftSize);
source.connect(analyser);
running = true; level = 0; peak = 0;
labels();
loop();
}
function stop(){
running = false;
if(rafId) cancelAnimationFrame(rafId);
rafId = null;
if(stream){ stream.getTracks().forEach(t => t.stop()); stream = null; }
analyser = null;
level = 0;
labels();
draw();
}
toggle.addEventListener("click", ()=>{ if(running) stop(); else start(); });
z1.addEventListener("input", ()=>{
zoneGreen = Math.min(+z1.value, +z2.value - 5);
z1.value = zoneGreen;
draw();
});
z2.addEventListener("input", ()=>{
zoneYellow = Math.max(+z2.value, zoneGreen + 5);
z2.value = zoneYellow;
draw();
});
/* geluid over rood laten lopen is een stoorzender op zich - een korte,
zachte waarschuwing volstaat, geen herhaald alarm */
const watchWarn = ()=>{
clearInterval(warnTimer);
warnTimer = setInterval(()=>{
if(running && currentZone() === "red") sndBad();
}, 6000);
};
watchWarn();
function labels(){
root.querySelector(".nm-mission").textContent = T("wg_noise_d");
toggle.textContent = running ? "⏸ " + T("noiseStop") : "🎙️ " + T("noiseStart");
root.querySelectorAll(".nm-zone-lbl")[0].textContent = T("noiseZoneGreen");
root.querySelectorAll(".nm-zone-lbl")[1].textContent = T("noiseZoneYellow");
root.querySelector(".nm-privacy").textContent = T("noisePrivacy");
draw();
}
document.addEventListener("langchange", labels);
labels();
return { getState: ()=>({ zoneGreen, zoneYellow }) };
}