diff --git a/services/bstein-dev-home/av-observer/index.html b/services/bstein-dev-home/av-observer/index.html index 5db27f16..a6101484 100644 --- a/services/bstein-dev-home/av-observer/index.html +++ b/services/bstein-dev-home/av-observer/index.html @@ -9,6 +9,7 @@
+Stopped
Reporting starts with the test.
diff --git a/services/bstein-dev-home/av-observer/kustomization.yaml b/services/bstein-dev-home/av-observer/kustomization.yaml index b2169498..bb6b6efd 100644 --- a/services/bstein-dev-home/av-observer/kustomization.yaml +++ b/services/bstein-dev-home/av-observer/kustomization.yaml @@ -15,6 +15,10 @@ configMapGenerator: - metrics.js - reporting.js - activity-worklet.js + - paired-observer.js + - paired-worklet.js + - paired-signals.js + - test-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 e6b9cd3d..66de020a 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|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|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 368a0004..a44f8475 100644 --- a/services/bstein-dev-home/av-observer/observer.js +++ b/services/bstein-dev-home/av-observer/observer.js @@ -1,5 +1,6 @@ import {VideoMetrics,receipt} from './metrics.js'; import {RunReporter} from './reporting.js'; +import {PairedObserver} from './paired-observer.js'; const el=id=>document.getElementById(id); let generation=0, current=null, last=null, reportingRun=null; const stopTracks=stream=>stream?.getTracks().forEach(track=>track.stop()); @@ -19,6 +20,7 @@ function stop(reason='operator') { if(run) { release(run); last=receipt(run.video,run.audio,run.settings,performance.now()-run.started,reason); + if(run.paired)last.paired=run.paired.summary(); run.reporter.finish(last); el('metrics').textContent=JSON.stringify(last,null,2); el('export').disabled=false; @@ -26,6 +28,7 @@ function stop(reason='operator') { el('preview').srcObject=null; el('start').disabled=!el('audio-device').value || !el('video-device').value; el('stop').disabled=true; el('devices').disabled=false; + el('paired-signal').disabled=false; el('status').textContent=`Stopped · ${reason}`; } el('devices').onclick=async()=>{ @@ -61,13 +64,14 @@ el('start').onclick=async()=>{ el('status').textContent='This browser lacks the required observation APIs.';return; } const ticket=++generation; - const run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{}}; + const run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{},pairedEnabled:el('paired-signal').checked}; current=run; last=null; reportingRun=run; run.reporter=new RunReporter(message=>{ if(reportingRun===run)el('report-status').textContent=message; }); el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true; + el('paired-signal').disabled=true; el('status').textContent='Starting…'; run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000); try { @@ -83,14 +87,17 @@ el('start').onclick=async()=>{ audio:{sampleRate:as.sampleRate,channelCount:as.channelCount,echoCancellation:as.echoCancellation, noiseSuppression:as.noiseSuppression,autoGainControl:as.autoGainControl}}; run.context=new AudioContext(); - await run.context.audioWorklet.addModule('activity-worklet.js'); + await run.context.audioWorklet.addModule(run.pairedEnabled?'paired-worklet.js':'activity-worklet.js'); if(generation!==ticket)return; - run.node=new AudioWorkletNode(run.context,'av-activity'); + if(run.pairedEnabled)run.paired=new PairedObserver(run.context,el('preview')); + run.node=new AudioWorkletNode(run.context,run.pairedEnabled?'av-paired-signals':'av-activity'); run.node.onprocessorerror=()=>{if(current===run)stop('audio observer error');}; run.node.port.onmessage=({data})=>{ if(current!==run)return; - run.audio=data; run.node.port.postMessage('ack'); + if(run.paired){run.audio=data.activity;run.paired.audioMessage(data);}else run.audio=data; + run.node.port.postMessage('ack'); 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); if(run.observing)run.reporter.observe(sample); }; @@ -107,6 +114,7 @@ el('start').onclick=async()=>{ function frame(now,metadata) { if(current!==run)return; run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames); + run.paired?.frame(metadata.expectedDisplayTime); run.callback=el('preview').requestVideoFrameCallback(frame); } run.callback=el('preview').requestVideoFrameCallback(frame); diff --git a/services/bstein-dev-home/av-observer/paired-observer.js b/services/bstein-dev-home/av-observer/paired-observer.js new file mode 100644 index 00000000..c9024436 --- /dev/null +++ b/services/bstein-dev-home/av-observer/paired-observer.js @@ -0,0 +1,53 @@ +import {colorCode,PulseEdges} from './test-signals.js'; +import {pairedSummary} from './paired-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; + } + audioMessage(data) { + if(!Array.isArray(data.signals)||data.signals.length>32){this.clockInvalid=true;return;} + this.overflow=Math.max(this.overflow,data.overflow??0); + 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);} + } + summary() { + const result=pairedSummary(this.audio,this.frames); + if(this.clockInvalid||this.overflow)result.estimatedMedianSignedMs=null; + return {...result,audioSignals:this.audio.length,videoSignals:this.frames.length, + overflow:this.overflow,clockInvalid:this.clockInvalid, + 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 new file mode 100644 index 00000000..d62796f3 --- /dev/null +++ b/services/bstein-dev-home/av-observer/paired-signals.js @@ -0,0 +1,45 @@ +// Pair already-recognized synthetic identities in a COMMON measured clock. +// Refuse multiple same-code candidates instead of choosing the smallest skew. +// Repeated palettes without an unambiguous time bound are not unique IDs. +const valid=e=>e&&Number.isInteger(e.code)&&e.code>=1&&e.code<=16&& + Number.isFinite(e.startMs)&&Number.isFinite(e.endMs)&&e.startMs>=0&&e.endMs>=e.startMs&& + 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}={}) { + const result={pairs:0,ambiguous:0,unpairedAudio:audio.length,unpairedVideo:video.length, + skewAbsP95UpperMs:null,skewAbsMaxUpperMs:null,medianSignedMs:null,estimatedMedianSignedMs:null, + clockUncertaintyMs:null,releaseEligible:false}; + if(audio.length>256||video.length>256||!audio.every(valid)||!video.every(valid)|| + !Number.isFinite(maxSkewMs)||maxSkewMs<=0||maxSkewMs>10000)return result; + 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)); + 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