av-test: measure bounded signal continuity and reject cyclic pairing
This commit is contained in:
parent
6ee8eb2e6c
commit
1f5329b001
70
services/bstein-dev-home/av-observer/continuity-signals.js
Normal file
70
services/bstein-dev-home/av-observer/continuity-signals.js
Normal 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};
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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); }
|
||||
};
|
||||
|
||||
@ -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.'};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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++) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user