import {VideoMetrics,receipt} from './metrics.js'; import {RunReporter} from './reporting.js'; import {PairedObserver} from './paired-observer.js'; import {reviewControls} from './review-controls.js'; import {releaseCapture,stopTracks} from './capture-lifecycle.js'; import {ContinuityWatch,WATCH_MS} from './continuity-watch.js'; const el=id=>document.getElementById(id); const review=reviewControls(document); let generation=0, current=null, last=null, reportingRun=null; function modeControls() { const watch=el('observation-mode').value==='watch'; el('paired-signal').disabled=watch||current!==null; review.lock(watch||current!==null); el('watch-help').hidden=!watch; el('start').textContent=watch?'Start 10-hour watch':'Start 2-minute check'; } el('observation-mode').onchange=()=>{ if(current)return; if(el('observation-mode').value==='watch') { el('paired-signal').checked=false;el('record-review').checked=false;review.discard(); } modeControls(); }; function release(run) { releaseCapture(run,el('preview'),()=>review.finish()); } function update(run) { if(current!==run)return; const sample=receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing'); if(run.paired)sample.paired=run.paired.summary(); if(run.watch) { run.watch.observe(sample,run.audioReceivedAt); sample.watch=run.watch.summary(); } el('metrics').textContent=JSON.stringify(sample,null,2); if(run.observing)run.reporter.observe(sample); } 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); if(run.paired)last.paired=run.paired.summary(); if(run.watch) { run.watch.observe(last,run.audioReceivedAt);run.watch.finish(reason); last.watch=run.watch.summary(true); } run.reporter.finish(last); const display=run.watch?{...last,watch:run.watch.summary()}:last; el('metrics').textContent=JSON.stringify(display,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('observation-mode').disabled=false;modeControls(); 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 a 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 a camera and microphone, 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 watchEnabled=el('observation-mode').value==='watch'; const run={started:performance.now(),video:new VideoMetrics(),audio:null,audioReceivedAt:null, settings:{},watchEnabled,pairedEnabled:!watchEnabled&&el('paired-signal').checked}; current=run; last=null; reportingRun=run; run.reporter=watchEnabled?{ observe(){},finish(){if(reportingRun===run)el('report-status').textContent='Local watch · Save metrics before closing';}, }:new RunReporter(message=>{ if(reportingRun===run)el('report-status').textContent=message; }); if(watchEnabled){review.discard();el('report-status').textContent='Local watch · metrics are not uploaded';} el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true; el('paired-signal').disabled=true; el('observation-mode').disabled=true; review.lock(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(run.pairedEnabled?'paired-worklet.js':'activity-worklet.js'); if(generation!==ticket)return; if(run.pairedEnabled)run.paired=new PairedObserver(run.context,el('preview')); run.node=new AudioWorkletNode(run.context,run.pairedEnabled?'av-paired-signals':'av-activity', {processorOptions:{continuityWatch:watchEnabled}}); run.node.onprocessorerror=()=>{if(current===run)stop('audio observer error');}; run.node.port.onmessage=({data})=>{ if(current!==run)return; if(watchEnabled&&data.watchStopped===true){stop('audio observer deadline or lost owner');return;} run.audioReceivedAt=performance.now(); if(run.paired){run.audio=data.activity;run.paired.audioMessage(data);}else run.audio=data; run.node.port.postMessage('ack'); if(run.observing&&run.paired&&!run.signalStarted) { const paired=run.paired.summary(); if(paired.continuity.frames>=2&&paired.pilot?.windows>0) { run.signalStarted=true;clearTimeout(run.signalTimer); run.started=performance.now();run.video=new VideoMetrics(); run.timer=setTimeout(()=>{if(current===run)stop('120 seconds complete');},120000); el('status').textContent='Measuring known A/V signals · 120 seconds'; } } update(run); }; 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; if(run.context.state!=='running')throw new Error('audio context did not start'); el('preview').srcObject=stream; await el('preview').play(); if(generation!==ticket)return; clearTimeout(run.setupTimer); run.started=performance.now(); if(watchEnabled)run.watch=new ContinuityWatch(); run.observing=true; // Reporting and freshness cannot depend on the audio path still working. run.updateTimer=setInterval(()=>update(run),1000); run.recording=watchEnabled?false:review.begin(stream); function frame(now,metadata) { if(current!==run)return; try { run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames); run.paired?.frame(metadata.expectedDisplayTime); run.callback=el('preview').requestVideoFrameCallback(frame); } catch {if(current===run)stop('video observer error');} } run.callback=el('preview').requestVideoFrameCallback(frame); if(watchEnabled) { run.timer=setTimeout(()=>{if(current===run)stop('10 hours observed');},WATCH_MS); el('status').textContent='Watching continuity · 10-hour limit · local metrics only'; } else if(run.paired) { run.signalTimer=setTimeout(()=>{if(current===run)stop('test signal timeout');},60000); el('status').textContent='Waiting for known A/V signals · start the source now'; } else { run.timer=setTimeout(()=>{if(current===run)stop('120 seconds complete');},120000); el('status').textContent=run.recording?'Observing both devices · recording locally':'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=last.watch?'av-continuity-watch.json':'av-test-metrics.json';link.click(); setTimeout(()=>URL.revokeObjectURL(url),1000); }; window.addEventListener('pagehide',()=>{stop('page closed');review.discard();}); document.addEventListener('visibilitychange',()=>{if(document.hidden&¤t)stop('page hidden; timing invalid');});