jenkins 9204773e7d av-test: publish bounded continuity watch and recent observations
Vendor tested Lesavka observer assets and numeric receipt schema from 6b97ceb1. Verified 65 JS tests, 10 service tests, kustomize render and Kubernetes client dry-run. No media upload, capture activation or changes to the homepage and private OCR.
2026-10-22 21:47:13 -05:00

86 lines
4.8 KiB
JavaScript

import {colorCode,PulseEdges} from './test-signals.js';
import {pairedSummary} from './paired-signals.js';
import {VideoContinuity} from './continuity-signals.js';
// Browser adapter. Source/device timestamps are not supplied by these APIs;
// this clock map is only a processing-time estimate, never release evidence.
// https://www.w3.org/TR/webaudio/#dom-baseaudiocontext-currenttime
// https://wicg.github.io/video-rvfc/
export class PairedObserver {
constructor(context,video,now=()=>performance.now()) {
this.context=context;this.video=video;this.audio=[];this.frames=[];
this.now=now;this.pilotReceivedAt=null;
this.edges=new PulseEdges();this.previous=null;this.overflow=0;this.clockInvalid=false;
this.canvas=document.createElement('canvas');this.canvas.width=16;this.canvas.height=16;
this.drawing=this.canvas.getContext('2d',{willReadFrequently:true});
this.offset=null;
this.continuity=new VideoContinuity();this.pilot=null;
this.stripe=document.createElement('canvas');this.stripe.width=20;this.stripe.height=1;
this.stripeContext=this.stripe.getContext('2d',{willReadFrequently:true});
}
audioMessage(data) {
if(!Array.isArray(data.signals)||data.signals.length>32){this.clockInvalid=true;return;}
this.overflow=Math.max(this.overflow,data.overflow??0);
this.pilot=data.pilot??null;
const before=this.now(),clock=this.context.currentTime*1000,after=this.now();
this.pilotReceivedAt=after;
if(this.context.state!=='running'||after-before>5||!Number.isFinite(clock)){this.clockInvalid=true;return;}
const offset=(before+after)/2-clock;
if(this.offset!==null&&Math.abs(offset-this.offset)>25)this.clockInvalid=true;
this.offset=offset;
for(const event of data.signals) {
if(this.audio.length>=256){this.overflow++;continue;}
this.audio.push({code:event.code,startMs:event.startMs+offset,endMs:event.endMs+offset});
}
}
frame(timeMs) {
if(!this.drawing||!this.video.videoWidth||!this.video.videoHeight)return;
let pixels;
try {
// Small transient centre sample. Clear both the canvas and pixel
// array immediately: no retained image, recording or upload.
const w=this.video.videoWidth,h=this.video.videoHeight;
this.drawing.drawImage(this.video,w/4,h/4,w/2,h/2,0,0,16,16);
pixels=this.drawing.getImageData(0,0,16,16).data;
const code=colorCode(pixels);
const previous=this.previous;this.previous=timeMs;
if(previous===null||timeMs<=previous||timeMs-previous>250)return;
const event=this.edges.observe(code,previous,timeMs);
if(event){if(this.frames.length<256)this.frames.push(event);else this.overflow++;}
} catch {this.clockInvalid=true;}
finally {pixels?.fill(0);this.drawing.clearRect(0,0,16,16);}
if(this.stripeContext) {
let stripe;
try {
const w=this.video.videoWidth,h=this.video.videoHeight;
this.stripeContext.drawImage(this.video,0,h-3,w,1,0,0,20,1);
stripe=this.stripeContext.getImageData(0,0,20,1).data;
this.continuity.observe(stripe,timeMs);
} catch {this.clockInvalid=true;}
finally {stripe?.fill(0);this.stripeContext.clearRect(0,0,20,1);}
}
}
summary() {
const result=pairedSummary(this.audio,this.frames,{uniqueContext:true});
if(this.clockInvalid||this.overflow)result.estimatedMedianSignedMs=null;
return {...result,audioSignals:this.audio.length,videoSignals:this.frames.length,
overflow:this.overflow,clockInvalid:this.clockInvalid,
continuity:this.continuity.summary(),pilot:this.pilotSummary(),
note:'Coded signal / browser processing-time estimate only; input-clock calibration and source age are unmeasured.'};
}
pilotSummary() {
const recent=this.pilot?.recent;
if(!recent)return this.pilot;
const elapsed=this.now()-this.pilotReceivedAt;
const contextAge=this.context.currentTime*1000-recent.endMs;
const valid=this.pilotReceivedAt!==null&&recent.endMs!==null&&
Number.isFinite(elapsed)&&elapsed>=0&&Number.isFinite(contextAge)&&contextAge>=0&&
Number.isFinite(recent.observationAgeMs)&&recent.observationAgeMs>=0;
// The worklet publishes at 1 Hz. Age the cached observation even if
// messages or the audio clock stop; missing callbacks are not silence.
const age=valid?Math.max(contextAge,recent.observationAgeMs+elapsed):null;
return {...this.pilot,recent:{...recent,observationAgeMs:age,
coverageComplete:recent.coverageComplete===true&&!this.clockInvalid&&age!==null&&age<=2000}};
}
}