All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
- 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.
124 lines
4.9 KiB
JavaScript
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 }) };
|
|
}
|