Some checks failed
Tests / Declarative: Post Actions failed: 42, skipped: 89, passed: 4273
118 lines
6.2 KiB
JavaScript
118 lines
6.2 KiB
JavaScript
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');});
|