86 lines
4.5 KiB
JavaScript
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;
|
|
}
|
|
}
|