Add new frequency graph

This commit is contained in:
Lewis Moten
2024-05-12 04:12:28 -04:00
parent 51a51d7e96
commit 015dd3eae3
8 changed files with 387 additions and 64 deletions

View File

@@ -5,6 +5,7 @@ class AvailableFskPairsPanel extends BasePanel {
super('Available FSK Pairs');
this.exclude = [];
this.fskPairs = [];
this.originalSelectedFskPairs = [];
this.sampleRate = 48000;
this.addCanvas('fsk-spectrum', 200, 32);
@@ -28,9 +29,25 @@ class AvailableFskPairsPanel extends BasePanel {
} else if(!this.exclude.includes(event.id)) {
this.exclude.push(event.id);
}
this.checkChanges();
this.drawFskSpectrum();
};
checkChanges = () => {
const selected = this.getSelectedFskPairs();
const original = this.originalSelectedFskPairs;
let changed = false;
if(original.length !== selected.length) {
changed = true;
} else {
const hertz = selected.flat();
changed = original.flat().some((hz, i) => hz !== hertz[i]);
}
if(changed) {
this.originalSelectedFskPairs = selected;
this.dispatcher.emit('change', {selected});
}
}
getSelectedFskPairs = () => this.fskPairs
.filter(this.isSelected);
@@ -38,6 +55,7 @@ class AvailableFskPairsPanel extends BasePanel {
setSelectedIndexes = (values) => {
this.exclude = values;
this.setFskPairs(this.fskPairs);
this.checkChanges();
}
setFskPairs = fskPairs => {
@@ -51,6 +69,7 @@ class AvailableFskPairsPanel extends BasePanel {
eventName: 'select'
}));
this.replaceCheckedInputs('checkbox', 'fsk-pairs', items);
this.checkChanges();
this.drawFskSpectrum();
}

View File

@@ -195,6 +195,10 @@ class BasePanel {
const element = this.getElement(id);
element.innerHTML = html;
}
getNumberById = id => {
const value = this.getValueById(id);
return parseFloat(value);
}
getValueById = (id) => {
const element = this.getElement(id);
switch(element.tagName) {

View File

@@ -0,0 +1,249 @@
import BasePanel from './BasePanel';
class FrequencyGraphPanel extends BasePanel {
constructor() {
super('Frequency Graph');
this.fskPairs = [];
this.sampleRate = 48000;
this.samplingPeriod = 30;
this.signalStart = performance.now();
this.signalEnd = this.signalStart;
this.samples = [];
this.duration = 200;
this.amplitudeThreshold = 0;
this.addButton('toggle', 'Start', 'toggle');
this.addNewLine();
this.addCanvas('frequency-graph', 500, 150);
this.addEventListener('toggle', this.handleToggle);
};
setDurationMilliseconds = (millseconds) => {
this.duration = millseconds;
this.draw(false);
}
setSignalStart = milliseconds => {
this.signalStart = milliseconds;
}
setSignalEnd = milliseconds => {
this.signalEnd = milliseconds;
}
setSamplingPeriod = (milliseconds) => this.samplingPeriod = milliseconds;
setAmplitudeThreshold = value => {
this.amplitudeThreshold = value;
}
setSampleRate = (value) => {
this.sampleRate = value;
}
setFskPairs = fskPairs => {
this.fskPairs = fskPairs;
}
setAnalyser = (analyser) => {
this.analyser = analyser;
}
isRunning = () => !!this.intervalId || !!this.animationFrameId;
handleToggle = () => {
if(this.isRunning()) {
this.stop();
} else {
this.start();
}
}
start = () => {
this.setValueById('toggle', 'Stop');
if(!this.intervalId) {
this.intervalId = window.setInterval(this.collectSamples, 5);
}
if(!this.animationFrameId) {
this.animationFrameId = window.requestAnimationFrame(this.draw);
}
}
stop = () => {
this.setValueById('toggle', 'Start');
if(this.intervalId) {
window.clearInterval(this.intervalId);
this.intervalId = undefined;
}
if(this.animationFrameId) {
window.cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = undefined;
}
// final draw
this.draw(false);
}
collectSamples = () => {
// Nothing to collect
if(this.fskPairs.length === 0) return;
// Nothing to collect with
const analyser = this.analyser;
if(!analyser) return;
const frequencyResolution = this.sampleRate / analyser.fftSize;
const frequencies = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(frequencies);
const indexOfHz = hz => Math.round(hz/frequencyResolution);
const ampsFromHz = hz => frequencies[indexOfHz(hz)];
const ampsFromManyHz = fsk => fsk.map(ampsFromHz);
const now = performance.now();
this.samples.unshift({
time: now,
fskPairs: this.fskPairs.map(ampsFromManyHz)
});
this.samples = this.samples.filter(sample => now - sample.time < this.duration);
}
draw = () => {
const maxAmps = 280; // inflated for height
const ultimateFrequency = this.sampleRate / 2;
const canvas = this.getElement('frequency-graph');
const ctx = canvas.getContext('2d');
const {height, width} = canvas;
ctx.clearRect(0, 0, width, height);
let now;
if(this.samples.length > 1) {
now = this.samples[0].time;
this.fskPairs.forEach((fsk, fskIndex) => {
fsk.forEach((hz, hzIndex)=> {
ctx.beginPath();
const hue = Math.floor(hz/ultimateFrequency * 360);
if(hzIndex === 0) {
ctx.strokeStyle = `hsla(${hue}, 100%, 50%, 50%)`;
ctx.setLineDash([5, 5]);
} else {
ctx.strokeStyle = `hsl(${hue}, 100%, 50%)`;
ctx.setLineDash([]);
}
this.samples.forEach(({time, fskPairs}, i) => {
fsk = fskPairs[fskIndex];
if(!fsk) return; // configuration changed
let x = ((now - time) / this.duration) * width;
const percent = (fsk[hzIndex] / maxAmps);
let y = (1 - percent) * height;
if(i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.lineWidth = 1;
ctx.stroke();
})
})
};
ctx.setLineDash([]);
// Amplitude Threshold
ctx.strokeStyle = 'hsla(0, 0%, 100%, 20%)';
ctx.lineWidth = 1;
ctx.beginPath();
let y = height * (1-(this.amplitudeThreshold * 255) / maxAmps);
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
// sampling periods
if(!now) now = performance.now();
let lastPeriodStart = now - ((now - this.signalStart) % this.samplingPeriod);
let lastTextX = -1000;
ctx.lineWidth = 1;
this.lastCountX = -1000;
for(let time = lastPeriodStart; time > now - this.duration; time -= this.samplingPeriod) {
const end = time + this.samplingPeriod;
let rightX = ((now - time) / this.duration) * width;
let leftX = ((now - end) / this.duration) * width;
// Line for when period started
ctx.beginPath();
ctx.moveTo(rightX, 0);
ctx.lineTo(rightX, height);
ctx.strokeStyle = 'hsla(120, 100%, 100%, 50%)';
ctx.stroke();
let samplePeriodWidth = rightX - leftX;
ctx.fontSize = '24px';
// Sample Index
if(time >= this.signalStart && (this.signalEnd < this.signalStart || time < this.signalEnd)) {
const signalIndex = Math.floor((time - this.signalStart) / this.samplingPeriod);
let text = signalIndex.toLocaleString();
let size = ctx.measureText(text);
let textX = leftX + (samplePeriodWidth / 2) - (size.width / 2);
// far enough from prior text?
if(textX - lastTextX > size.width) {
lastTextX = textX;
ctx.lineWidth = 2;
ctx.textBaseline = 'bottom';
let textY = height - 12;
ctx.strokeStyle = 'black';
ctx.strokeText(text, textX, textY);
ctx.fillStyle = 'white';
ctx.fillText(text, textX, textY);
}
}
// sample counts
this.drawSampleCount(ctx, width, height, time, end, leftX, samplePeriodWidth);
}
this.drawSignalStart(ctx, width, height, now);
this.drawSignalEnd(ctx, width, height, now);
if(this.isRunning()) {
this.animationFrameId = requestAnimationFrame(this.draw);
}
}
drawSampleCount = (ctx, width, height, start, end, leftX, samplePeriodWidth) => {
const count = this.samples.filter(sample => {
return sample.time >= start && sample.time < end;
}).length;
let text = count.toLocaleString();
let size = ctx.measureText(text);
let textX = leftX + (samplePeriodWidth / 2) - (size.width / 2);
// far enough from prior text?
if(textX - this.lastCountX > size.width) {
this.lastCountX = textX;
ctx.lineWidth = 2;
ctx.textBaseline = 'bottom';
let textY = 20;
ctx.strokeStyle = 'black';
ctx.strokeText(text, textX, textY);
if(count === 0) {
ctx.fillStyle = 'red';
} else if(count < 3) {
ctx.fillStyle = 'yellow';
} else {
ctx.fillStyle = 'white';
}
ctx.fillText(text, textX, textY);
}
}
drawSignalStart = (ctx, width, height, now) => {
if(now - this.signalStart < this.duration) {
let x = ((now - this.signalStart) / this.duration) * width;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.lineWidth = 3;
ctx.strokeStyle = 'hsla(60, 100%, 50%, 50%)';
ctx.stroke();
}
};
drawSignalEnd = (ctx, width, height, now) => {
if(now - this.signalEnd < this.duration) {
let x = ((now - this.signalEnd) / this.duration) * width;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.lineWidth = 3;
ctx.strokeStyle = 'hsla(60, 100%, 50%, 50%)';
ctx.stroke();
}
}
}
export default FrequencyGraphPanel;

View File

@@ -0,0 +1,26 @@
import BasePanel from './BasePanel';
class GraphConfigurationPanel extends BasePanel {
constructor() {
super('Graphs');
this.addCheckboxes('checkboxes', [
{text: 'Pause after signal ends', id: 'pause-after-end', eventName: 'pauseAfterEndChange'}
])
this.openField('Duration');
this.addInputNumber('duration', 1, {min: 0.03, step: 0.001, eventName: 'durationChange'});
this.addText('s');
this.closeField();
};
getDurationSeconds = () => this.getNumberById('duration');
setDurationSeconds = (seconds) => this.setValueById('duration', seconds);
getDurationMilliseconds = () => this.getDurationSeconds() * 1000;
setDurationMilliseconds = (milliseconds) => this.setDurationSeconds(milliseconds / 1000);
getPauseAfterEnd = () => this.getCheckedById('pause-after-end');
setPauseAfterEnd = (value) => this.setCheckedById('pause-after-end', value);
}
export default GraphConfigurationPanel;

View File

@@ -28,12 +28,19 @@ class SignalPanel extends BasePanel {
this.addText('%');
this.closeField();
this.openField('Timeout');
this.addInputNumber('timeout', 30, {min: 30, max: 1000, eventName: 'timeoutChange'});
this.addText('ms');
this.closeField();
this.openField('Smoothing Time Constant');
this.addInputNumber('smoothing-time-constant', 0, {min: 0, max: 100, eventName: 'smothingTimeConstantChange', translation: 'percent'});
this.addText('%');
this.closeField();
};
getTimeoutMilliseconds = () => this.getNumberById('timeout');
setTimeoutMilliseconds = (milliseconds) => this.setValueById('timeout', milliseconds);
getWaveform = () => this.getValueById('wave-form');
setWaveform = (value) => this.setValueById('wave-form', value);