UNPKG

@rfkit/spectrum-analyzer

Version:

A high-performance spectrum analyzer library for RF signal processing, supporting real-time spectrum analysis, waterfall display, and multi-segment frequency scanning

1 lines 17.8 kB
class SpectrumError extends Error{code;details;constructor(message,code,details){super(message),this.code=code,this.details=details;this.name="SpectrumError"}}class DataValidationError extends SpectrumError{constructor(message,details){super(message,"DATA_VALIDATION_ERROR",details)}}class IndexOutOfBoundsError extends SpectrumError{index;constructor(message,index,details){super(message,"INDEX_OUT_OF_BOUNDS_ERROR",{index,...details||{}}),this.index=index}}const arrayKeepAttribute=(source,target)=>{if(void 0!==source.max)target.max=source.max;if(void 0!==source.maxIndex)target.maxIndex=source.maxIndex;if(void 0!==source.timestamp)target.timestamp=source.timestamp;if(void 0!==source.progress)target.progress=source.progress};const resample=({realData,antennaFactorData,antennaFactorSwitch=false,outputPoints})=>{const realDataLength=realData.length;const isLessThanDataLength=realDataLength<=outputPoints;const outputLength=isLessThanDataLength?realDataLength:outputPoints;const srcIndexCache=new Uint32Array(outputLength);const realOutputData=new Float32Array(outputLength);realOutputData.timestamp=realData.timestamp;if(isLessThanDataLength){if(!antennaFactorSwitch){for(let i=0;i<realDataLength;i++)srcIndexCache[i]=i;realOutputData.set(realData);return{realOutputData,srcIndexCache}}for(let i=0;i<realDataLength;i++){srcIndexCache[i]=i;realOutputData[i]=realData[i]+antennaFactorData[i]}return{realOutputData,srcIndexCache}}const ratio=realDataLength/outputPoints;let pos=ratio/2;for(let i=0;i<outputLength;i++){const start=Math.floor(pos);const end=Math.min(Math.floor(pos+ratio),realDataLength);let maxValue=realData[start];let maxIndex=start;for(let j=start+1;j<end;j++)if(realData[j]>maxValue){maxValue=realData[j];maxIndex=j}realOutputData[i]=antennaFactorSwitch?maxValue+antennaFactorData[maxIndex]:maxValue;srcIndexCache[i]=maxIndex;pos+=ratio}return{realOutputData,srcIndexCache}};const resampleMultiple=({antennaFactorData,antennaFactorSwitch=false,outputPoints,realData,maxData,minData,avgData})=>{const realDataLength=realData.length;const isLessThanDataLength=realDataLength<=outputPoints;const outputLength=isLessThanDataLength?realDataLength:outputPoints;const hasMaxData=maxData&&maxData.length>0;const hasMinData=minData&&minData.length>0;const hasAvgData=avgData&&avgData.length>0;const realOutputData=new Float32Array(outputLength);realOutputData.timestamp=realData.timestamp;const maxOutputData=new Float32Array(hasMaxData?outputLength:0);const minOutputData=new Float32Array(hasMinData?outputLength:0);const avgOutputData=new Float32Array(hasAvgData?outputLength:0);const srcIndexCache=new Uint32Array(outputLength);if(isLessThanDataLength){for(let i=0;i<realDataLength;i++)srcIndexCache[i]=i;if(antennaFactorSwitch)for(let i=0;i<realDataLength;i++){const antennaFactor=antennaFactorData[i];realOutputData[i]=realData[i]+antennaFactor;if(hasMaxData)maxOutputData[i]=maxData[i]+antennaFactor;if(hasMinData)minOutputData[i]=minData[i]+antennaFactor;if(hasAvgData)avgOutputData[i]=avgData[i]+antennaFactor}else{realOutputData.set(realData);if(hasMaxData)maxOutputData.set(maxData);if(hasMinData)minOutputData.set(minData);if(hasAvgData)avgOutputData.set(avgData)}}else{const ratio=realDataLength/outputPoints;let pos=ratio/2;const findMaxInRange=(data,start,end)=>{let maxVal=data[start];let maxIdx=start;for(let j=start+1;j<end;j++)if(data[j]>maxVal){maxVal=data[j];maxIdx=j}return{maxVal,maxIdx}};const findMinInRange=(data,start,end)=>{let minVal=data[start];let minIdx=start;for(let j=start+1;j<end;j++)if(data[j]<minVal){minVal=data[j];minIdx=j}return{minVal,minIdx}};for(let i=0;i<outputLength;i++){const start=Math.floor(pos);const end=Math.min(Math.floor(pos+ratio),realDataLength);const{maxVal:maxValue,maxIdx:maxIndex}=findMaxInRange(realData,start,end);realOutputData[i]=antennaFactorSwitch?maxValue+antennaFactorData[maxIndex]:maxValue;srcIndexCache[i]=maxIndex;if(hasMaxData){const{maxVal:maxDataValue,maxIdx:maxDataIndex}=findMaxInRange(maxData,start,end);maxOutputData[i]=antennaFactorSwitch?maxDataValue+antennaFactorData[maxDataIndex]:maxDataValue}if(hasMinData){const{minVal:minValue,minIdx:minIndex}=findMinInRange(minData,start,end);minOutputData[i]=antennaFactorSwitch?minValue+antennaFactorData[minIndex]:minValue}if(hasAvgData){const avgValue=avgData[maxIndex];avgOutputData[i]=antennaFactorSwitch?avgValue+antennaFactorData[maxIndex]:avgValue}pos+=ratio}}return{realOutputData,maxOutputData,minOutputData,srcIndexCache,avgOutputData}};const SPECTRUM={INITIAL_VALUE:Number.NaN,WATERFALL_MAX_FRAMES:100,OUTPUT_POINTS:1001};const DEFAULT_SPECTRUM_CONFIG={maxPoints:SPECTRUM.OUTPUT_POINTS,waterfallMaxFrames:SPECTRUM.WATERFALL_MAX_FRAMES,initialValue:SPECTRUM.INITIAL_VALUE,processing:{enableWaterfall:false,enableMetrics:false},outputPoints:SPECTRUM.OUTPUT_POINTS,outputRange:{start:0,end:SPECTRUM.OUTPUT_POINTS}};const ERROR_MESSAGES={EMPTY_SEGMENTS:"频段配置不能为空",INVALID_CONFIG:"必须且只能配置 segments 或 bandwidthConfig 其中之一",EMPTY_BANDWIDTH:"bandwidthConfig 不能为空",INVALID_SEGMENT:index=>`无效的段索引: ${index}`,INDEX_OUT_OF_BOUNDS:index=>`索引超出范围: ${index}`,INVALID_ANTENNA_FACTOR_LENGTH:points=>`天线因子数据长度必须等于实时数据长度 (${points})`,INVALID_ANTENNA_FACTOR:"天线因子数据必须是有效的正数",INVALID_SAMPLING_RANGE:"采样范围无效",INVALID_MAX_POINTS:"点数必须大于0",INVALID_LENGTH:expected=>`频率占用度数据长度不匹配,期望长度为 ${expected}`};class SpectrumAnalyzer{config=DEFAULT_SPECTRUM_CONFIG;segments=[];antennaFactorData;antennaFactorSwitch;realData;maxData;minData;avgData;waterfallData;srcIndexCache;realOutputData;waterfallOutputData;scanProgress;lastIndex;processTimes;lastProcessTime;onSpectrumUpdate;constructor(config){const{onSpectrumUpdate,maxPoints}=config;this.onSpectrumUpdate=onSpectrumUpdate;this.config={...DEFAULT_SPECTRUM_CONFIG,...config,processing:{...DEFAULT_SPECTRUM_CONFIG.processing,...config.processing},outputRange:{start:config.outputRange?.start??0,end:config.outputRange?.end??maxPoints??SPECTRUM.OUTPUT_POINTS}};this.reset()}process({data,timestamp,segmentOffset=0,offset=0}){const processStartTime=performance.now();try{if(!data?.length)return;let index=offset;if(this.segments.length){const segment=this.segments[segmentOffset];if(!segment)throw new DataValidationError(ERROR_MESSAGES.INVALID_SEGMENT(segmentOffset));index=segment.startIndex+offset;this.scanProgress=(index+data.length)/this.config.maxPoints}else if(data.length!==this.config.maxPoints)this.updateMaxPoints(data.length);this.validateInput(data,index);const{maxPoints}=this.config;const endIndex=index+data.length;this.lastIndex=endIndex;const isOver=endIndex>=maxPoints||endIndex<this.lastIndex;this.realData.set(data,index);this.realData.timestamp=timestamp;this.processDataPoints(data,index,isOver);const processedData=this.resampleDataSeries();if(isOver)this.updateWaterfallData(this.realData,processedData.realData);this.notifySpectrumUpdate({...processedData,waterfallData:this.waterfallOutputData,scanProgress:this.scanProgress,processTimes:this.processTimes})}catch(error){throw error instanceof Error?error:new Error(String(error))}finally{this.lastProcessTime=performance.now()-processStartTime}}initializeSegments(segments){if(!segments?.length)throw new DataValidationError(ERROR_MESSAGES.EMPTY_SEGMENTS);let totalPoints=0;this.segments=segments.map(segment=>{const{startFrequency,stopFrequency,stepFrequency}=segment;const pointCount=Math.floor((stopFrequency-startFrequency)*1e3/stepFrequency)+1;const startIndex=totalPoints;totalPoints+=pointCount;return{startFrequency,stopFrequency,stepFrequency,pointCount,startIndex}});this.updateMaxPoints(totalPoints)}setAntennaFactor(d){const{antennaFactorData,config:{maxPoints}}=this;let data=new Float32Array(d);if(data.length!==maxPoints){data=new Float32Array(data).subarray(0,maxPoints);console.warn(ERROR_MESSAGES.INVALID_ANTENNA_FACTOR_LENGTH(maxPoints))}let hasInvalid=false;for(let i=0;i<data.length;i++){const value=data[i];const isNiceFinite=Number.isFinite(value)&&value>0;if(!isNiceFinite)hasInvalid=true;antennaFactorData[i]=isNiceFinite?value:0}if(hasInvalid)console.warn(ERROR_MESSAGES.INVALID_ANTENNA_FACTOR);if(this.antennaFactorSwitch)this.setAntennaFactorSwitch(this.antennaFactorSwitch)}setAntennaFactorSwitch(newAntennaFactorSwitch){const isChange=this.antennaFactorSwitch!==newAntennaFactorSwitch;this.antennaFactorSwitch=newAntennaFactorSwitch;if(isChange){const processedData=this.resampleDataSeries();this.resampleWaterfallOutputData();this.notifySpectrumUpdate({...processedData,waterfallData:this.waterfallOutputData})}}setWaterfallData(newWaterfallData){if(!(newWaterfallData?.[0]?.length>0))return;const{processing}=this.config;let{waterfallMaxFrames}=this.config;if(!processing.enableWaterfall)return;waterfallMaxFrames=newWaterfallData.length;this.config={...this.config,waterfallMaxFrames};this.waterfallData=newWaterfallData;this.resampleWaterfallOutputData();this.notifySpectrumUpdate({waterfallData:this.waterfallOutputData})}setRealData(data){if(!(data?.length>0)||data.length!==this.config.maxPoints)return;const newData=new Float32Array(data.length);newData.set(data);newData.timestamp=data.timestamp;this.realData=newData;const processedData=this.resampleDataSeries();this.notifySpectrumUpdate(processedData)}setMaxData(data){if(!(data?.length>0)||data.length!==this.config.maxPoints)return;const newData=new Float32Array(data.length);newData.set(data);this.maxData=newData;this.maxData.allValid=this.isAllValid(this.maxData);const processedData=this.resampleDataSeries();this.notifySpectrumUpdate(processedData)}reset(){const{maxPoints,waterfallMaxFrames,processing:{enableMetrics}}=this.config;this.antennaFactorData=new Float32Array(maxPoints);const realData=new Float32Array(maxPoints);realData.timestamp=0;realData.fill(SPECTRUM.INITIAL_VALUE);this.realData=realData;this.maxData=new Float32Array(enableMetrics?maxPoints:0).fill(SPECTRUM.INITIAL_VALUE);this.minData=new Float32Array(enableMetrics?maxPoints:0).fill(SPECTRUM.INITIAL_VALUE);this.avgData=new Float32Array(enableMetrics?maxPoints:0).fill(SPECTRUM.INITIAL_VALUE);this.waterfallData=Array.from({length:waterfallMaxFrames},()=>{const frame=new Float32Array;frame.timestamp=0;frame.fill(SPECTRUM.INITIAL_VALUE);return frame});this.waterfallOutputData=Array.from({length:waterfallMaxFrames},()=>{const frame=new Float32Array;frame.timestamp=0;frame.fill(SPECTRUM.INITIAL_VALUE);return frame});this.srcIndexCache=new Uint32Array(maxPoints);this.scanProgress=0;this.lastIndex=0;this.processTimes=0;this.lastProcessTime=0;this.notifySpectrumUpdate({realData:this.realData,maxData:this.maxData,minData:this.minData,avgData:this.avgData,srcIndexCache:this.srcIndexCache,processTimes:0,scanProgress:0})}getData(){const{realData,maxData,minData,avgData,srcIndexCache,waterfallData,processTimes}=this;return{realData,maxData,minData,avgData,srcIndexCache,waterfallData,processTimes}}getPerformanceMetrics(){return{lastProcessTime:this.lastProcessTime,dataPoints:this.config.maxPoints,waterfallFrames:this.waterfallData.length,isInitialized:this.realData.length>0,memoryUsage:this.calculateMemoryUsage()}}updateSamplingRange(start,end){if(start<0||start>=end)throw new DataValidationError(ERROR_MESSAGES.INVALID_SAMPLING_RANGE);this.config={...this.config,outputRange:{start:Math.floor(start),end:Math.ceil(end)}};const processedData=this.resampleDataSeries();this.resampleWaterfallOutputData();this.notifySpectrumUpdate({...processedData,waterfallData:this.waterfallOutputData});return this.srcIndexCache}resampleDataSeries(){const{antennaFactorData,antennaFactorSwitch,maxData,minData,avgData,config:{maxPoints,outputPoints,processing:{enableMetrics}}}=this;const activeAntennaFactorData=antennaFactorSwitch?antennaFactorData:new Float32Array(maxPoints);const{realOutputData,srcIndexCache,maxOutputData,minOutputData,avgOutputData}=resampleMultiple({antennaFactorData:activeAntennaFactorData,antennaFactorSwitch,outputPoints,realData:this.getOutputData(),enableMetrics,maxData:this.getOutputData(maxData),minData:this.getOutputData(minData),avgData:this.getOutputData(avgData)});this.srcIndexCache=srcIndexCache;return{realData:realOutputData,maxData:maxOutputData,minData:minOutputData,avgData:avgOutputData,srcIndexCache}}resampleWaterfallOutputData(){const{antennaFactorData,antennaFactorSwitch,waterfallData,config:{maxPoints,processing:{enableWaterfall},outputPoints}}=this;if(!enableWaterfall)return;const activeAntennaFactorData=antennaFactorSwitch?antennaFactorData:new Float32Array(maxPoints);this.waterfallOutputData=waterfallData.map(frame=>{const realData=this.getOutputData(frame);const{realOutputData}=resampleMultiple({realData,antennaFactorData:activeAntennaFactorData,antennaFactorSwitch,outputPoints});return realOutputData})}updateWaterfallData(data,outputData){const{waterfallMaxFrames,processing}=this.config;if(!processing.enableWaterfall)return;if(this.waterfallData.length>=waterfallMaxFrames){this.waterfallData.shift();this.waterfallOutputData.shift()}const newData=new Float32Array(data.length);newData.set(data);newData.timestamp=data.timestamp;this.waterfallData.push(newData);this.waterfallOutputData.push(outputData)}updateMaxPoints(maxPoints){if(this.config.maxPoints===maxPoints)return;this.config={...this.config,maxPoints,outputRange:{start:0,end:maxPoints}};this.reset()}validateInput(data,index){if(index<0||index+data.length>this.config.maxPoints)throw new IndexOutOfBoundsError(ERROR_MESSAGES.INDEX_OUT_OF_BOUNDS(index),index)}processDataPoints(data,index,isOver){const{maxData,minData,avgData,config:{processing:{enableMetrics}}}=this;if(!enableMetrics)return;if(isOver){this.processTimes+=1;if(!maxData.allValid)maxData.allValid=this.isAllValid(maxData);if(!minData.allValid)minData.allValid=this.isAllValid(minData);if(!avgData.allValid)avgData.allValid=this.isAllValid(avgData)}let allValid=true;const length=data.length;for(let i=0;i<length;i++)if(!Number.isFinite(data[i])){allValid=false;break}for(let i=0;i<length;i++){const dataIndex=index+i;const value=data[i];if(!allValid&&!Number.isFinite(value))continue;const oldMax=maxData[dataIndex];if(value>oldMax||!maxData.allValid&&Number.isNaN(oldMax))maxData[dataIndex]=value;const oldMin=minData[dataIndex];if(value<oldMin||!minData.allValid&&Number.isNaN(oldMin))minData[dataIndex]=value;const oldAvg=avgData[dataIndex];if(0===this.processTimes||Number.isNaN(oldAvg))avgData[dataIndex]=value;else avgData[dataIndex]=oldAvg+(value-oldAvg)/(this.processTimes+1)}}isAllValid(arr){for(let i=0;i<arr.length;i++)if(!Number.isFinite(arr[i]))return false;return true}notifySpectrumUpdate(processedData){this.onSpectrumUpdate?.(processedData)}calculateMemoryUsage(){const arraySize=this.config.maxPoints*Float32Array.BYTES_PER_ELEMENT;const baseArrayMemory=4*arraySize;const rawWaterfallMemory=this.waterfallData.reduce((sum,frame)=>sum+frame.length*Float32Array.BYTES_PER_ELEMENT,0);const outputWaterfallMemory=this.waterfallOutputData.reduce((sum,frame)=>sum+frame.length*Float32Array.BYTES_PER_ELEMENT,0);return baseArrayMemory+rawWaterfallMemory+outputWaterfallMemory}getOutputData(data){const{outputRange:{start,end}}=this.config;const sourceData=data??this.realData;const outputData=sourceData.subarray(start,end);outputData.timestamp=sourceData.timestamp;return outputData}getAllRawData(){const{realData,maxData,minData,avgData,waterfallData}=this;return{realData,maxData,minData,avgData,waterfallData}}}const LEVEL_STREAM={DEFAULT_CACHE_TIME:15e3,DEFAULT_GRANULARITY:10,DEFAULT_RANGE:[-20,100]};const DEFAULT_LEVEL_STREAM_CONFIG={cacheTime:LEVEL_STREAM.DEFAULT_CACHE_TIME,granularity:LEVEL_STREAM.DEFAULT_GRANULARITY,range:LEVEL_STREAM.DEFAULT_RANGE,onLevelStreamUpdate:data=>{}};class LevelStreamAnalyzer{config;spectrumData=[];probabilityData=new Map;count=0;constructor(config){this.config={...DEFAULT_LEVEL_STREAM_CONFIG,...config}}reset(){this.spectrumData=[];this.probabilityData=new Map;this.count=0}setConfig(config){this.config={...DEFAULT_LEVEL_STREAM_CONFIG,...this.config,...config};if(config.cacheTime&&this.config.cacheTime!==config.cacheTime||config.granularity&&this.config.granularity!==config.granularity)this.reset();if(config.range)this.outputData()}process(level){this.removeExpiredData();this.addNewData(level);this.updateProbability(level);this.outputData()}updateProbability(level){const{granularity}=this.config;this.count++;const bin=Math.round(level/granularity)*granularity;let binCount=this.probabilityData.get(bin)||0;binCount++;this.probabilityData.set(bin,binCount)}getAll(){return this.probabilityData}removeExpiredData(){const now=Date.now();const{cacheTime}=this.config;if(cacheTime<=0){this.spectrumData=[];return}if(0===this.spectrumData.length)return;let left=0;let right=this.spectrumData.length-1;while(left<=right){const mid=Math.floor((left+right)/2);if(now-this.spectrumData[mid].timestamp>=cacheTime)left=mid+1;else right=mid-1}this.spectrumData=this.spectrumData.slice(left)}addNewData(level){const now=Date.now();this.spectrumData.push({value:level,timestamp:now})}outputData(){const{range,granularity,onLevelStreamUpdate}=this.config;const rangeMin=range[0];const rangeMax=range[1];const probabilityRangeData=new Array(Math.round((rangeMax-rangeMin)/granularity)+1).fill(0);for(const[bin,count]of this.probabilityData)if(bin>=rangeMin&&bin<=rangeMax){const index=Math.round((rangeMax-bin)/granularity);if(this.count>0)probabilityRangeData[index]=count/this.count*100}onLevelStreamUpdate?.({probabilityRangeData:new Float32Array(probabilityRangeData),spectrumData:new Float32Array(this.spectrumData.map(item=>item.value))})}}export{DataValidationError,ERROR_MESSAGES,IndexOutOfBoundsError,LevelStreamAnalyzer,SPECTRUM,SpectrumAnalyzer,SpectrumError};