jenkins e035cadb4d
Some checks failed
Tests / Declarative: Post Actions failed: 42, skipped: 89, passed: 4273
lesavka: host no-upload receiver observer at dedicated HTTPS path
2026-10-02 17:02:43 -05:00

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&&current)stop('page hidden; timing invalid');});