From 1f5329b001e8dd01fe8abb81ebc10af384abf4e2 Mon Sep 17 00:00:00 2001 From: jenkins Date: Sun, 4 Oct 2026 03:45:01 -0500 Subject: [PATCH] av-test: measure bounded signal continuity and reject cyclic pairing --- .../av-observer/continuity-signals.js | 70 +++++++++++++++++++ .../av-observer/kustomization.yaml | 1 + .../bstein-dev-home/av-observer/nginx.conf | 2 +- .../bstein-dev-home/av-observer/observer.js | 20 +++++- .../av-observer/paired-observer.js | 18 ++++- .../av-observer/paired-signals.js | 12 +++- .../av-observer/paired-worklet.js | 8 ++- .../av-observer/report-service/validation.py | 5 ++ .../bstein-dev-home/av-observer/reporting.js | 12 +++- 9 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 services/bstein-dev-home/av-observer/continuity-signals.js diff --git a/services/bstein-dev-home/av-observer/continuity-signals.js b/services/bstein-dev-home/av-observer/continuity-signals.js new file mode 100644 index 00000000..a381a781 --- /dev/null +++ b/services/bstein-dev-home/av-observer/continuity-signals.js @@ -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=.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}; + } +} diff --git a/services/bstein-dev-home/av-observer/kustomization.yaml b/services/bstein-dev-home/av-observer/kustomization.yaml index bb6b6efd..0c803895 100644 --- a/services/bstein-dev-home/av-observer/kustomization.yaml +++ b/services/bstein-dev-home/av-observer/kustomization.yaml @@ -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: diff --git a/services/bstein-dev-home/av-observer/nginx.conf b/services/bstein-dev-home/av-observer/nginx.conf index 66de020a..f4025d97 100644 --- a/services/bstein-dev-home/av-observer/nginx.conf +++ b/services/bstein-dev-home/av-observer/nginx.conf @@ -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; } diff --git a/services/bstein-dev-home/av-observer/observer.js b/services/bstein-dev-home/av-observer/observer.js index a44f8475..c76a96a6 100644 --- a/services/bstein-dev-home/av-observer/observer.js +++ b/services/bstein-dev-home/av-observer/observer.js @@ -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); } }; diff --git a/services/bstein-dev-home/av-observer/paired-observer.js b/services/bstein-dev-home/av-observer/paired-observer.js index c9024436..530fe633 100644 --- a/services/bstein-dev-home/av-observer/paired-observer.js +++ b/services/bstein-dev-home/av-observer/paired-observer.js @@ -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.'}; } } diff --git a/services/bstein-dev-home/av-observer/paired-signals.js b/services/bstein-dev-home/av-observer/paired-signals.js index d62796f3..fa141af2 100644 --- a/services/bstein-dev-home/av-observer/paired-signals.js +++ b/services/bstein-dev-home/av-observer/paired-signals.js @@ -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{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. diff --git a/services/bstein-dev-home/av-observer/report-service/validation.py b/services/bstein-dev-home/av-observer/report-service/validation.py index 99648560..8be3da60 100644 --- a/services/bstein-dev-home/av-observer/report-service/validation.py +++ b/services/bstein-dev-home/av-observer/report-service/validation.py @@ -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, } diff --git a/services/bstein-dev-home/av-observer/reporting.js b/services/bstein-dev-home/av-observer/reporting.js index 7c248ab8..4693f82e 100644 --- a/services/bstein-dev-home/av-observer/reporting.js +++ b/services/bstein-dev-home/av-observer/reporting.js @@ -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