149 lines
8.0 KiB
JavaScript

import {VideoMetrics,receipt} from './metrics.js';
import {RunReporter} from './reporting.js';
import {PairedObserver} from './paired-observer.js';
const el=id=>document.getElementById(id);
let generation=0, current=null, last=null, reportingRun=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); clearTimeout(run.signalTimer);
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);
if(run.paired)last.paired=run.paired.summary();
run.reporter.finish(last);
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('paired-signal').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 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 run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{},pairedEnabled:el('paired-signal').checked};
current=run; last=null;
reportingRun=run;
run.reporter=new RunReporter(message=>{
if(reportingRun===run)el('report-status').textContent=message;
});
el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true;
el('paired-signal').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(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');
run.node.onprocessorerror=()=>{if(current===run)stop('audio observer error');};
run.node.port.onmessage=({data})=>{
if(current!==run)return;
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';
}
}
const sample=receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing');
if(run.paired)sample.paired=run.paired.summary();
el('metrics').textContent=JSON.stringify(sample,null,2);
if(run.observing)run.reporter.observe(sample);
};
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();
run.observing=true;
function frame(now,metadata) {
if(current!==run)return;
run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames);
run.paired?.frame(metadata.expectedDisplayTime);
run.callback=el('preview').requestVideoFrameCallback(frame);
}
run.callback=el('preview').requestVideoFrameCallback(frame);
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='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='av-test-metrics.json';link.click();
setTimeout(()=>URL.revokeObjectURL(url),1000);
};
window.addEventListener('pagehide',()=>stop('page closed'));
document.addEventListener('visibilitychange',()=>{if(document.hidden&&current)stop('page hidden; timing invalid');});