import {VideoMetrics,receipt} from './metrics.js'; const el=id=>document.getElementById(id); let generation=0, current=null, last=null; const stopTracks=stream=>stream?.getTracks().forEach(track=>track.stop()); function release(run) { if(run.released)return; run.released=true; clearTimeout(run.timer); clearTimeout(run.setupTimer); if(run.callback)el('preview').cancelVideoFrameCallback(run.callback); run.node?.port.postMessage('stop'); run.node?.disconnect(); run.source?.disconnect(); stopTracks(run.stream); // Do not wait on a browser AudioContext close before fencing a stopped run. run.context?.close().catch(()=>{}); } function stop(reason='operator') { generation++; const run=current; current=null; if(run) { release(run); last=receipt(run.video,run.audio,run.settings,performance.now()-run.started,reason); el('metrics').textContent=JSON.stringify(last,null,2); el('export').disabled=false; } el('preview').srcObject=null; el('start').disabled=!el('audio-device').value || !el('video-device').value; el('stop').disabled=true; el('devices').disabled=false; el('status').textContent=`Stopped · ${reason}`; } el('devices').onclick=async()=>{ stop('selecting devices'); const ticket=++generation; el('devices').disabled=true; el('start').disabled=true; el('stop').disabled=false; el('status').textContent='Requesting device permission…'; const timer=setTimeout(()=>{if(generation===ticket)stop('permission timeout');},20000); try { const stream=await navigator.mediaDevices.getUserMedia({audio:true,video:true}); stopTracks(stream); if(generation!==ticket)return; const devices=await navigator.mediaDevices.enumerateDevices(); if(generation!==ticket)return; for(const [id,kind] of [['video-device','videoinput'],['audio-device','audioinput']]) { const select=el(id), saved=select.value; select.replaceChildren(new Option('Select Lesavka device','')); for(const d of devices.filter(d=>d.kind===kind)) select.add(new Option(d.label||'Unnamed device',d.deviceId)); select.value=saved; } el('status').textContent='Select both Lesavka devices, then Start.'; el('devices').disabled=false; el('stop').disabled=true; } catch { if(generation===ticket)stop('device permission unavailable'); } finally {clearTimeout(timer);} }; for(const id of ['audio-device','video-device']) el(id).onchange=()=>{ if(current)stop('device selection changed'); el('start').disabled=!el('audio-device').value||!el('video-device').value; }; el('start').onclick=async()=>{ if(current || !el('audio-device').value || !el('video-device').value)return; if(!el('preview').requestVideoFrameCallback || !window.AudioWorkletNode) { el('status').textContent='This browser lacks the required observation APIs.';return; } const ticket=++generation; const run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{}}; current=run; last=null; el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true; el('status').textContent='Starting…'; run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000); try { const stream=await navigator.mediaDevices.getUserMedia({ video:{deviceId:{exact:el('video-device').value}}, audio:{deviceId:{exact:el('audio-device').value},echoCancellation:false,noiseSuppression:false,autoGainControl:false}, }); if(generation!==ticket){stopTracks(stream);return;} run.stream=stream; for(const track of stream.getTracks())track.onended=()=>{if(current===run)stop('device ended');}; const vs=stream.getVideoTracks()[0].getSettings(),as=stream.getAudioTracks()[0].getSettings(); run.settings={video:{width:vs.width,height:vs.height,frameRate:vs.frameRate}, audio:{sampleRate:as.sampleRate,channelCount:as.channelCount,echoCancellation:as.echoCancellation, noiseSuppression:as.noiseSuppression,autoGainControl:as.autoGainControl}}; run.context=new AudioContext(); await run.context.audioWorklet.addModule('activity-worklet.js'); if(generation!==ticket)return; run.node=new AudioWorkletNode(run.context,'av-activity'); run.node.onprocessorerror=()=>{if(current===run)stop('audio observer error');}; run.node.port.onmessage=({data})=>{ if(current!==run)return; run.audio=data; run.node.port.postMessage('ack'); el('metrics').textContent=JSON.stringify(receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing'),null,2); }; run.source=run.context.createMediaStreamSource(new MediaStream(stream.getAudioTracks())); run.source.connect(run.node);run.node.connect(run.context.destination); await run.context.resume(); if(generation!==ticket)return; el('preview').srcObject=stream; await el('preview').play(); if(generation!==ticket)return; clearTimeout(run.setupTimer); run.started=performance.now(); function frame(now,metadata) { if(current!==run)return; run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames); run.callback=el('preview').requestVideoFrameCallback(frame); } run.callback=el('preview').requestVideoFrameCallback(frame); run.timer=setTimeout(()=>{if(current===run)stop('120 seconds complete');},120000); el('status').textContent='Observing both devices · no recording'; } catch { if(current===run)stop('capture/setup failed'); } finally { if(current!==run)release(run); } }; el('stop').onclick=()=>stop(); el('export').onclick=()=>{ if(!last)return; const url=URL.createObjectURL(new Blob([JSON.stringify(last,null,2)],{type:'application/json'})); const link=document.createElement('a');link.href=url;link.download='lesavka-av-observer.json';link.click(); setTimeout(()=>URL.revokeObjectURL(url),1000); }; window.addEventListener('pagehide',()=>stop('page closed')); document.addEventListener('visibilitychange',()=>{if(document.hidden&¤t)stop('page hidden; timing invalid');});