86 lines
4.5 KiB
JavaScript

// Streaming recognition of the existing synthetic coded-pulse protocol.
// No samples/images are retained. A match identifies a diagnostic signal,
// not arbitrary content, a transport timestamp or a calibrated browser clock.
const tones=[620,780,940,1120,1320,1540,1780,2040,2320,2620,2960,3340,3760,4220,4740,5320];
const colors=[[255,45,45],[0,230,118],[41,121,255],[255,179,0],
[216,27,96],[0,188,212],[205,220,57],[126,87,194],
[255,112,67],[38,166,154],[255,64,129],[92,107,192],
[255,235,59],[105,240,174],[171,71,188],[3,169,244]];
const normalized=colors.map(rgb=>rgb.map(v=>v/Math.max(...rgb)));
export function colorCode(rgba) {
if(!rgba || rgba.length<16 || rgba.length>4096 || rgba.length%4)return 0;
const mean=[0,0,0];const low=[255,255,255],high=[0,0,0];
for(let i=0;i<rgba.length;i+=4)for(let c=0;c<3;c++) {
const v=rgba[i+c];if(!Number.isFinite(v)||v<0||v>255)return 0;
mean[c]+=v;low[c]=Math.min(low[c],v);high[c]=Math.max(high[c],v);
}
for(let c=0;c<3;c++)mean[c]/=rgba.length/4;
const max=Math.max(...mean),min=Math.min(...mean);
if(max<70 || max-min<50 || high.some((v,i)=>v-low[i]>45))return 0;
const rgb=mean.map(v=>v/max);
const distances=normalized.map((p,index)=>({code:index+1,
distance:Math.sqrt(p.reduce((sum,v,i)=>sum+(rgb[i]-v)**2,0))})).sort((a,b)=>a.distance-b.distance);
if(distances[0].distance>.13 || distances[1].distance-distances[0].distance<.035)return 0;
return distances[0].code;
}
// Two consecutive observations confirm an onset; short detector dropouts do
// not manufacture another event. start/end bound the FIRST matching window.
export class PulseEdges {
constructor(){this.active=0;this.candidate=null;this.off=0;this.last=-Infinity;this.lastStart=-Infinity;}
observe(code,startMs,endMs) {
if(!Number.isInteger(code)||code<0||code>16||!Number.isFinite(startMs)||
!Number.isFinite(endMs)||startMs<0||endMs<startMs||startMs<this.lastStart||endMs<this.last) {
this.active=0;this.candidate=null;this.off=0;return null;
}
this.last=endMs;this.lastStart=startMs;
if(!code){if(++this.off>=2){this.active=0;this.candidate=null;}return null;}
this.off=0;
if(code===this.active)return null;
if(this.candidate?.code!==code){this.candidate={code,startMs,endMs};return null;}
const result=this.candidate;this.active=code;this.candidate=null;return result;
}
}
export class ToneDetector {
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=tones.map(f=>2*Math.cos(2*Math.PI*f/rate));
this.q1=new Float64Array(16);this.q2=new Float64Array(16);
this.edges=new PulseEdges();this.expected=null;this.reset();
}
reset(){this.q1.fill(0);this.q2.fill(0);this.count=0;this.energy=0;}
// frame is AudioWorklet currentFrame, not message delivery time.
process(samples,frame) {
if(!Number.isSafeInteger(frame)||frame<0||samples.length>8192)throw new RangeError('audio block');
if(this.expected!==null&&frame!==this.expected){this.reset();this.edges=new PulseEdges();}
this.expected=frame+samples.length;
const events=[];
for(let n=0;n<samples.length;n++) {
const x=samples[n];
if(!Number.isFinite(x)){this.reset();this.edges=new PulseEdges();continue;}
if(!this.count)this.start=frame+n;
this.energy+=x*x;
for(let i=0;i<16;i++) {
const q=x+this.coeff[i]*this.q1[i]-this.q2[i];
this.q2[i]=this.q1[i];this.q1[i]=q;
}
if(++this.count!==this.size)continue;
let best=0,second=0,code=0;
for(let i=0;i<16;i++) {
const power=this.q1[i]**2+this.q2[i]**2-this.coeff[i]*this.q1[i]*this.q2[i];
if(power>best){second=best;best=power;code=i+1;}else second=Math.max(second,power);
}
if(this.energy/this.size<1e-6 || 2*best/(this.size*this.energy)<.75 || best<second*4)code=0;
// A partial preceding window may be too weak to classify. Include
// it in the onset bound rather than pretending detection is exact.
const event=this.edges.observe(code,Math.max(0,this.start-this.size)/this.rate*1000,
(frame+n+1)/this.rate*1000);
if(event)events.push(event);
this.reset();
}
return events;
}
}