70 lines
3.8 KiB
JavaScript
70 lines
3.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) {
|
|
this.context=context;this.video=video;this.audio=[];this.frames=[];
|
|
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=performance.now(),clock=this.context.currentTime*1000,after=performance.now();
|
|
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.pilot,
|
|
note:'Coded signal / browser processing-time estimate only; input-clock calibration and source age are unmeasured.'};
|
|
}
|
|
}
|