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.'};
}
}