// Receiver observation only: no media, identities, storage, network or PASS. // A watch never resets capture to produce a new reporting interval. export const WATCH_MS=10*60*60*1000; export const WATCH_ROWS=1202; // One row per 30 seconds plus start/terminal. const integer=value=>Number.isSafeInteger(value)&&value>=0; const number=value=>Number.isFinite(value)&&value>=0; export class ContinuityWatch { constructor(now=()=>performance.now(),wall=()=>Date.now()) { this.now=now;this.wall=wall;this.started=now();this.wallStarted=wall(); this.previousAt=this.started;this.nextRow=0;this.rows=[];this.findings={}; this.previous=null;this.lastAudioProgress=0;this.lastVideoProgress=0; this.latest=null;this.terminal=null;this.maxObserverGap=0; } note(kind,elapsed) { const finding=this.findings[kind]??{firstObservedMs:elapsed,observations:0}; finding.observations++;this.findings[kind]=finding; } observe(sample,audioReceivedAt) { if(this.terminal)return; const now=this.now(),elapsed=now-this.started,wallElapsed=this.wall()-this.wallStarted; if(!number(elapsed)||!number(wallElapsed)||now2500)this.note('observer-coverage-gap',elapsed); if(Math.abs(wallElapsed-elapsed)>2000)this.note('wall-monotonic-divergence',elapsed); const audio=sample.audio??{},video=sample.video??{}; const audioAge=audioReceivedAt===null?elapsed:now-audioReceivedAt; const row={elapsedMs:elapsed,videoCallbacks:video.callbacks,videoResets:video.clockOrCounterResets, audioInputFrames:audio.inputFrames,audioProcessedFrames:audio.processedFrames, audioZeroFrames:audio.exactZeroFrames,audioRate:audio.sampleRate, audioMessageAgeMs:number(audioAge)?audioAge:null, videoCallbackAgeMs:number(video.observationAgeMs)?video.observationAgeMs:null, videoGapMaxMs:number(video.callbackGapMaxMs)?video.callbackGapMaxMs:null}; for(const key of ['videoCallbacks','videoResets','audioInputFrames','audioProcessedFrames','audioZeroFrames','audioRate']) { if(!integer(row[key]))row[key]=null; } if(row.audioMessageAgeMs===null||row.audioMessageAgeMs>2000)this.note('audio-observation-stale',elapsed); if(row.videoCallbackAgeMs===null||row.videoCallbackAgeMs>2000) { if(elapsed>2000)this.note('video-observation-stale',elapsed); } if(row.audioInputFrames===null||row.audioProcessedFrames===null||row.audioRate===null) { if(elapsed>2000)this.note('audio-counters-unavailable',elapsed); } const previous=this.previous; if(previous) { for(const key of ['videoCallbacks','audioInputFrames','audioProcessedFrames','audioZeroFrames']) { if(row[key]!==null&&previous[key]!==null&&row[key]previous.videoResets) this.note('video-clock-or-counter-reset',elapsed); } if(row.audioInputFrames!==null&&(!previous||row.audioInputFrames>previous.audioInputFrames))this.lastAudioProgress=elapsed; if(row.videoCallbacks!==null&&(!previous||row.videoCallbacks>previous.videoCallbacks))this.lastVideoProgress=elapsed; if(elapsed-this.lastAudioProgress>2000)this.note('audio-input-not-advancing',elapsed); if(elapsed-this.lastVideoProgress>2000)this.note('video-not-advancing',elapsed); this.previous=row;this.latest=row; if(elapsed>=this.nextRow) { this.append(row);this.nextRow=elapsed+30000; } } append(row) { if(this.rows.lengththis.findings[key]); this.terminal=reason==='10 hours observed'&&duration>=WATCH_MS&&!observerIncomplete? (Object.keys(this.findings).length?'duration-observed-with-findings':'duration-observed'):'incomplete'; } summary(includeTimeline=false) { return {kind:'receiver-continuity-diagnostic',state:this.terminal??'observing', startedUnixMs:integer(this.wallStarted)?this.wallStarted:null,wallClockCalibrated:false, requestedDurationMs:WATCH_MS,observedDurationMs:this.latest?.elapsedMs??0, maxObserverGapMs:this.maxObserverGap,findings:structuredClone(this.findings), retainedRows:this.rows.length,rowLimit:WATCH_ROWS,latest:this.latest?{...this.latest}:null, ...(includeTimeline?{timeline:this.rows.map(row=>({...row}))}:{}), releaseEligible:false,mediaRecorded:false,uploaded:false, note:'Browser observations only. Silence is not loss; clocks and callback gaps do not prove audible quality or A/V sync.'}; } }