av-test: measure bounded signal continuity and reject cyclic pairing

This commit is contained in:
jenkins 2026-10-04 03:45:01 -05:00
parent 6ee8eb2e6c
commit 1f5329b001
9 changed files with 136 additions and 12 deletions

View File

@ -0,0 +1,70 @@
// Known synthetic signatures only. No PCM/frame retention or content upload.
export class VideoContinuity {
constructor(){this.frames=0;this.bad=0;this.duplicates=0;this.missing=0;this.ambiguous=0;this.previous=null;}
observe(rgba,timeMs) {
// Waiting for the known source is not a scored part of the test.
const reject=()=>{if(this.previous){this.frames++;this.bad++;}};
if(!rgba||rgba.length!==80||!Number.isFinite(timeMs)){reject();return;}
const bits=[];
for(let i=0;i<20;i++) {
const rgb=Array.from(rgba.slice(i*4,i*4+3));
if(rgb.some(x=>!Number.isFinite(x)||x<0||x>255)||Math.max(...rgb)-Math.min(...rgb)>35) {reject();return;}
const mean=rgb.reduce((a,b)=>a+b,0)/3;
if(mean>70&&mean<185){reject();return;}
bits.push(mean>=185?1:0);
}
const value=bits.slice(2,18).reduce((n,b)=>(n<<1)|b,0);
const parity=bits.slice(2,18).reduce((a,b)=>a^b,0);
if(bits[0]!==1||bits[1]!==0||bits[18]!==parity||bits[19]===parity||value>=16) {reject();return;}
this.frames++;
if(this.previous) {
const gap=timeMs-this.previous.time,delta=(value-this.previous.value+16)%16;
// Large time/sequence gaps can hide wraps. Unknown is never zero.
if(gap<=0||gap>100||delta>8)this.ambiguous++;
else if(delta===0)this.duplicates++;
else this.missing+=delta-1;
}
this.previous={time:timeMs,value};
}
summary() {
return {frames:this.frames,badMarkers:this.bad,ambiguousGaps:this.ambiguous,
duplicateFraction:this.frames&&!this.ambiguous?this.duplicates/this.frames:null,
missingFraction:this.frames&&!this.ambiguous?this.missing/(this.frames+this.missing):null};
}
}
export class AudioPilot {
constructor(rate) {
if(!Number.isFinite(rate)||rate<16000||rate>192000)throw new RangeError('sample rate');
this.rate=rate;this.size=Math.round(rate*.01);this.coeff=2*Math.cos(2*Math.PI*7000/rate);
this.q1=0;this.q2=0;this.count=0;this.windows=0;this.missing=0;this.pending=0;
this.maxGap=0;this.first=null;this.last=null;this.expected=null;this.discontinuities=0;
}
process(samples,frame) {
if(this.expected!==null&&frame!==this.expected) {
this.discontinuities++;this.q1=0;this.q2=0;this.count=0;
}
this.expected=frame+samples.length;
for(let i=0;i<samples.length;i++) {
const x=samples[i];
if(!Number.isFinite(x)){this.discontinuities++;this.q1=0;this.q2=0;this.count=0;continue;}
const q=x+this.coeff*this.q1-this.q2;this.q2=this.q1;this.q1=q;
if(++this.count!==this.size)continue;
const power=this.q1**2+this.q2**2-this.coeff*this.q1*this.q2;
const present=2*Math.sqrt(Math.max(0,power))/this.size>=.008;
const now=(frame+i+1)/this.rate*1000;
if(present) {
if(this.first===null)this.first=now-this.size/this.rate*1000;
this.last=now;this.windows+=1+this.pending;this.missing+=this.pending;
this.maxGap=Math.max(this.maxGap,this.pending*10);this.pending=0;
} else if(this.first!==null)this.pending++;
this.q1=0;this.q2=0;this.count=0;
}
}
summary() {
return {windows:this.windows,firstMs:this.first,lastMs:this.last,
missingFraction:this.windows&&!this.discontinuities?this.missing/this.windows:null,
gapMaxMs:this.windows&&!this.discontinuities?this.maxGap:null,
trailingGapMs:this.pending*10,discontinuities:this.discontinuities};
}
}

View File

@ -19,6 +19,7 @@ configMapGenerator:
- paired-worklet.js
- paired-signals.js
- test-signals.js
- continuity-signals.js
- style.css
- name: lesavka-av-observer-receipts
files:

View File

@ -34,7 +34,7 @@ http {
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
try_files /av-test/index.html =404;
}
location ~ ^/av-test/(index\.html|observer\.js|metrics\.js|reporting\.js|activity-worklet\.js|paired-observer\.js|paired-worklet\.js|paired-signals\.js|test-signals\.js|style\.css)$ {
location ~ ^/av-test/(index\.html|observer\.js|metrics\.js|reporting\.js|activity-worklet\.js|paired-observer\.js|paired-worklet\.js|paired-signals\.js|test-signals\.js|continuity-signals\.js|style\.css)$ {
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
try_files $uri =404;
}

View File

@ -7,7 +7,7 @@ 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);
clearTimeout(run.timer); clearTimeout(run.setupTimer); clearTimeout(run.signalTimer);
if(run.callback)el('preview').cancelVideoFrameCallback(run.callback);
run.node?.port.postMessage('stop'); run.node?.disconnect(); run.source?.disconnect();
stopTracks(run.stream);
@ -96,6 +96,15 @@ el('start').onclick=async()=>{
if(current!==run)return;
if(run.paired){run.audio=data.activity;run.paired.audioMessage(data);}else run.audio=data;
run.node.port.postMessage('ack');
if(run.observing&&run.paired&&!run.signalStarted) {
const paired=run.paired.summary();
if(paired.continuity.frames>=2&&paired.pilot?.windows>0) {
run.signalStarted=true;clearTimeout(run.signalTimer);
run.started=performance.now();run.video=new VideoMetrics();
run.timer=setTimeout(()=>{if(current===run)stop('120 seconds complete');},120000);
el('status').textContent='Measuring known A/V signals · 120 seconds';
}
}
const sample=receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing');
if(run.paired)sample.paired=run.paired.summary();
el('metrics').textContent=JSON.stringify(sample,null,2);
@ -118,8 +127,13 @@ el('start').onclick=async()=>{
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';
if(run.paired) {
run.signalTimer=setTimeout(()=>{if(current===run)stop('test signal timeout');},60000);
el('status').textContent='Waiting for known A/V signals · start the source now';
} else {
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); }
};

View File

@ -1,5 +1,6 @@
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.
@ -12,10 +13,14 @@ export class PairedObserver {
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;
@ -42,12 +47,23 @@ export class PairedObserver {
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);
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.'};
}
}

View File

@ -6,7 +6,7 @@ const valid=e=>e&&Number.isInteger(e.code)&&e.code>=1&&e.code<=16&&
e.endMs-e.startMs<=250;
const quantile=(values,p)=>values.length?[...values].sort((a,b)=>a-b)[Math.ceil(values.length*p)-1]:null;
export function pairedSummary(audio,video,{clockUncertaintyMs=null,maxSkewMs=2000}={}) {
export function pairedSummary(audio,video,{clockUncertaintyMs=null,maxSkewMs=2000,uniqueContext=false}={}) {
const result={pairs:0,ambiguous:0,unpairedAudio:audio.length,unpairedVideo:video.length,
skewAbsP95UpperMs:null,skewAbsMaxUpperMs:null,medianSignedMs:null,estimatedMedianSignedMs:null,
clockUncertaintyMs:null,releaseEligible:false};
@ -15,8 +15,14 @@ export function pairedSummary(audio,video,{clockUncertaintyMs=null,maxSkewMs=200
for(const events of [audio,video]) {
if(events.some((e,i)=>i&&e.startMs<=events[i-1].startMs))return result;
}
const candidates=audio.map(a=>video.map((v,i)=>a.code===v.code&&
Math.max(a.startMs-v.endMs,v.startMs-a.endMs)<=maxSkewMs?i:-1).filter(i=>i>=0));
// A source with unique adjacent pairs can be matched without selecting the
// nearest repeated palette. A missing pulse breaks context, not identity.
const context=(events,i)=>i>0&&events[i].startMs-events[i-1].startMs>=700&&
events[i].startMs-events[i-1].startMs<=1300?`${events[i-1].code}:${events[i].code}`:null;
const ak=audio.map((_,i)=>context(audio,i)),vk=video.map((_,i)=>context(video,i));
const candidates=audio.map((a,j)=>video.map((v,i)=>a.code===v.code&&
(uniqueContext ? ak[j]!==null&&ak[j]===vk[i] :
Math.max(a.startMs-v.endMs,v.startMs-a.endMs)<=maxSkewMs)?i:-1).filter(i=>i>=0));
const reverse=video.map((_,i)=>candidates.reduce((n,c)=>n+Number(c.includes(i)),0));
const pairs=[];let lastVideo=-1;
for(let i=0;i<audio.length;i++) {

View File

@ -1,18 +1,20 @@
import {ToneDetector} from './test-signals.js';
import {AudioPilot} from './continuity-signals.js';
// Explicit paired-signal mode only. Bounded numeric observations; never PCM.
class PairedSignals extends AudioWorkletProcessor {
constructor() {
super();this.detector=new ToneDetector(sampleRate);
super();this.detector=new ToneDetector(sampleRate);this.pilot=new AudioPilot(sampleRate);
this.frames=0;this.inputFrames=0;this.zeros=0;this.energy=0;this.peak=0;
this.events=[];this.overflow=0;this.credit=true;this.next=0;this.stopped=false;
this.port.onmessage=({data})=>{if(data==='ack')this.credit=true;if(data==='stop')this.stopped=true;};
}
process(inputs,outputs) {
if(this.stopped||this.frames>=sampleRate*125)return false;
if(this.stopped||this.frames>=sampleRate*185)return false;
this.frames+=outputs[0]?.[0]?.length??128;
const samples=inputs[0]?.[0];
if(samples) {
this.pilot.process(samples,currentFrame);
for(const x of samples){this.inputFrames++;this.energy+=x*x;this.peak=Math.max(this.peak,Math.abs(x));if(x===0)this.zeros++;}
for(const event of this.detector.process(samples,currentFrame)) {
if(this.events.length<32)this.events.push(event);else this.overflow++;
@ -21,7 +23,7 @@ class PairedSignals extends AudioWorkletProcessor {
if(this.credit&&this.frames>=this.next) {
this.port.postMessage({activity:{inputFrames:this.inputFrames,processedFrames:this.frames,
exactZeroFrames:this.zeros,rms:this.inputFrames?Math.sqrt(this.energy/this.inputFrames):0,
peak:this.peak,sampleRate},signals:this.events,overflow:this.overflow});
peak:this.peak,sampleRate},signals:this.events,overflow:this.overflow,pilot:this.pilot.summary()});
this.events=[];this.credit=false;this.next=this.frames+sampleRate;
}
// Browser initializes every output block to zero. No mic monitoring.

View File

@ -14,6 +14,11 @@ BOUNDS = {
"paired_audio_signals": 256, "paired_video_signals": 256,
"paired_matches": 256, "paired_ambiguous": 256, "paired_overflow": 100_000,
"paired_estimate_audio_late_ms": 10_000, "paired_estimate_audio_early_ms": 10_000,
"marker_frames": 100_000, "marker_bad": 100_000, "marker_ambiguous": 100_000,
"marker_missing_fraction": 1, "marker_duplicate_fraction": 1,
"pilot_windows": 18_000, "pilot_gap_max_ms": 180_000,
"pilot_missing_fraction": 1, "pilot_trailing_gap_ms": 180_000,
"pilot_discontinuities": 100_000_000,
}

View File

@ -3,6 +3,7 @@
export function reportMetrics(receipt) {
const finite=value=>typeof value==='number'&&Number.isFinite(value)&&value>=0 ? value : null;
const v=receipt.video??{},a=receipt.audio??{},s=receipt.settings??{},p=receipt.paired??{};
const c=p.continuity??{},pilot=p.pilot??{};
const estimate=typeof p.estimatedMedianSignedMs==='number'&&Number.isFinite(p.estimatedMedianSignedMs)?p.estimatedMedianSignedMs:null;
return Object.fromEntries(Object.entries({
elapsed_ms:receipt.elapsedMs,
@ -17,6 +18,11 @@ export function reportMetrics(receipt) {
paired_matches:p.pairs,paired_ambiguous:p.ambiguous,paired_overflow:p.overflow,
paired_estimate_audio_late_ms:estimate===null?null:Math.max(0,estimate),
paired_estimate_audio_early_ms:estimate===null?null:Math.max(0,-estimate),
marker_frames:c.frames,marker_bad:c.badMarkers,marker_ambiguous:c.ambiguousGaps,
marker_missing_fraction:c.missingFraction,marker_duplicate_fraction:c.duplicateFraction,
pilot_windows:pilot.windows,pilot_gap_max_ms:pilot.gapMaxMs,
pilot_missing_fraction:pilot.missingFraction,pilot_trailing_gap_ms:pilot.trailingGapMs,
pilot_discontinuities:pilot.discontinuities,
}).map(([key,value])=>[key,finite(value)]));
}
@ -50,7 +56,11 @@ export class RunReporter {
}
}
observe(receipt) {
if(this.terminal||receipt.elapsedMs-this.lastProgress<10000)return;
if(this.terminal)return;
// Paired tests reset elapsed time when the known source first arrives.
// A long signal wait must not suppress the next minute of progress.
if(receipt.elapsedMs<this.lastProgress)this.lastProgress=-Infinity;
if(receipt.elapsedMs-this.lastProgress<10000)return;
this.lastProgress=receipt.elapsedMs;this.queue('running',receipt);
}
finish(receipt) {