diff --git a/Panels/BasePanel.js b/Panels/BasePanel.js index 02eca3f..d4afe5a 100644 --- a/Panels/BasePanel.js +++ b/Panels/BasePanel.js @@ -27,45 +27,93 @@ class BasePanel { addCheckboxes = (name, items) => { this.addCheckedInputs('checkbox', name, items); }; + openField = name => this.addText(`${name}: `); + addText = text => this.append(document.createTextNode(text)); + addDynamicText = (id, text) => { + const span = document.createElement('span'); + span.id = this.childId(id); + span.innerText = text; + return this.append(span); + } + closeField = () => this.addNewLine(); + addNewLine = () => this.append(document.createElement('br')); + addDropdown = (id, items, eventName = 'change') => { + const select = document.createElement('select'); + select.id = this.childId(id); + select.addEventListener('change', (e) => { + const values = [...select.selectedOptions].map(option => option.value); + this.dispatcher.emit(eventName, { + id, + values + }); + }); + items.forEach((item, i) => { + const option = document.createElement('option'); + option.value = item.value; + option.text = item.text; + if(item.selected) { + if(select.selectedIndex === -1) { + select.selectedIndex = i; + } + option.selected = item.selected; + } + select.append(option); + }); + if(select.selectedIndex === -1 && items.length !== 0) { + select.selectedIndex = 0; + } + this.append(select); + } addCheckedInputs = (type, name, items, value) => { - items.forEach(({id, text, checked = false, eventName = 'change'}, index)=> { + items.forEach(({id, text, checked = false, eventName = 'change'})=> { const label = document.createElement('label'); - const input = document.createElement('input'); - input.type = type; - input.name = name; - input.checked = checked; - input.value = value; - input.id = this.childId(id); - input.addEventListener('change', e => { - this.dispatcher.emit(eventName, { - name, - id, - index, - checked: e.target.checked, - value - }); - }) + label.for = this.childId(id); + const input = this.createInput(id, value, {name, checked, type, eventName}); label.appendChild(input); const textNode = document.createTextNode(text); label.append(textNode); this.append(label); - const br = document.createElement('br'); - this.append(br); + this.addNewLine(); }); } - addInputText = (id, value, eventName = 'input') => { + addInputText = (id, value, options = {}) => { + this.append(this.createInput(id, value, {...options, type: 'text'})); + } + addInputNumber = (id, value, options = {}) => { + this.append(this.createInput(id, value, {...options, type: 'number'})); + } + createInput = (id, value = '', options = {}) => { + const { + eventName = 'input', + type = 'text', + translation, + ...attr + } = options; const input = document.createElement('input'); - input.type = 'text'; input.value = value; input.id = this.childId(id); - input.addEventListener('input', e => { - this.dispatcher.emit(eventName, { - panel: this.id, - id, - value + input.type = type; + if(['radio', 'checkbox'].includes(type)) { + input.addEventListener('change', e => { + this.dispatcher.emit(eventName, { + id, + checked: e.target.checked, + value + }); + }) + } else { + input.addEventListener('input', e => { + this.dispatcher.emit(eventName, { + panel: this.id, + id, + value: translateValue(e.target.value, translation) + }); }); - }); - this.append(input); + } + Object.keys(attr).forEach(key => { + input[key] = options[key]; + }) + return input; } addButton = (id, text, eventName = 'click') => { const button = document.createElement('button'); @@ -80,6 +128,13 @@ class BasePanel { }); this.append(button); } + addCanvas = (id, width, height) => { + const canvas = document.createElement('canvas'); + canvas.id = this.childId(id); + canvas.width = width; + canvas.height = height; + return this.append(canvas); + } addProgressBar = (id, percent) => { const progressBar = document.createElement('div'); progressBar.className = 'progress-container'; @@ -96,8 +151,10 @@ class BasePanel { element.style.width = `${clamp(percent, 0, 1) * 100}%`; } childId = id => `${this.id}-${id}`; + getElement = id => { - const element = document.getElementById(this.childId(id)); + const element = this.getDomElement().querySelector(`#${this.childId(id)}`); + // const element = document.getElementById(this.childId(id)); if(!element) throw new Error(`Unable to find ${id}`); return element; } @@ -142,5 +199,15 @@ class BasePanel { addEventListener = (eventName, callback) => this.dispatcher.addListener(eventName, callback); removeEventListener = (eventName, callback) => this.dispatcher.removeListener(eventName, callback); } +const translateValue = (value, translation) => { + if(!translation) return value; + if(translation === 'percent') { + return parseInt(value) / 100; + } else if(translation === 'power of 2') { + return 2 ** parseInt(value); + } + console.warn('Unknown translation', translation) + return value; +} export default BasePanel; \ No newline at end of file diff --git a/Panels/FrequencyPanel.js b/Panels/FrequencyPanel.js new file mode 100644 index 0000000..cad059f --- /dev/null +++ b/Panels/FrequencyPanel.js @@ -0,0 +1,176 @@ +import BasePanel from './BasePanel'; + +class FrequencyPanel extends BasePanel { + constructor(sampleRate = 48000) { + super('Frequencies'); + + this.sampleRate = sampleRate; + const ultimateFrequency = sampleRate / 2; + + this.openField('Minimum'); + this.addInputNumber('minimum-frequency', 0, {min: 0, max: ultimateFrequency, eventName: 'minimumFrequencyChange'}); + this.closeField(); + + this.openField('Maximum'); + this.addInputNumber('maximum-frequency', ultimateFrequency, {min: 0, max: ultimateFrequency, eventName: 'maximumFrequencyChange'}); + this.closeField(); + + this.openField('FFT Size'); + this.addText('2^'); + this.addInputNumber('fft-power', 10, {min: 5, max: 15, eventName: 'fftSizeChange', translation: 'power of 2'}); + this.addText(' '); + this.addDynamicText('fft-size', 'N/A'); + this.closeField(); + + this.openField('Frequency Resolution'); + this.addDynamicText('frequency-resolution-size', 'N/A'); + this.addText(' Hz'); + this.closeField(); + + this.openField('FSK Padding'); + this.addInputNumber('fsk-padding', 1, {min: 1, max: 20, eventName: 'fskPaddingChange'}); + this.closeField(); + + this.openField('Multi-FSK Padding'); + this.addInputNumber('multi-fsk-padding', 0, {min: 0, max: 20, eventName: 'multiFskPaddingChange'}); + this.closeField(); + + this.addCanvas('frequency-spectrum', 200, 32); + this.addNewLine(); + + this.openField('FSK Pairs Available'); + this.addDynamicText('fsk-count', 'N/A'); + this.closeField(); + + this.addEventListener('multiFskPaddingChange', this.checkFskPairsChanged); + this.addEventListener('fskPaddingChange', this.checkFskPairsChanged); + this.addEventListener('fftSizeChange', this.checkFskPairsChanged); + this.addEventListener('fftSizeChange', this.handleFftSizeChanged); + this.addEventListener('maximumFrequencyChange', this.checkFskPairsChanged); + this.addEventListener('minimumFrequencyChange', this.checkFskPairsChanged); + this.originalFskPairs = this.getFskPairs(); + this.drawFrequencySpectrum(); + }; + getMinimumFrequency = () => parseInt(this.getValueById('minimum-frequency')); + setMinimumFrequency = value => { + this.setValueById('minimum-frequency', value); + this.checkFskPairsChanged(); + }; + + getMaximumFrequency = () => parseInt(this.getValueById('maximum-frequency')); + setMaximumFrequency = value => { + this.setValueById('maximum-frequency', value); + this.checkFskPairsChanged(); + } + + getFftSize = () => 2 ** parseInt(this.getValueById('fft-power')); + setFftSize = (value) => { + this.setValueById('fft-power', Math.log2(value)); + this.handleFftSizeChanged(); + this.checkFskPairsChanged(); + } + + handleFftSizeChanged = () => { + const fftSize = this.getFftSize(); + this.setValueById('fft-size', fftSize.toLocaleString()); + const resolution = this.sampleRate / fftSize; + this.setValueById('frequency-resolution-size', parseFloat(resolution.toFixed(1)).toLocaleString()); + } + + getFskPadding = () => parseInt(this.getValueById('fsk-padding')); + setFskPadding = (value) => { + this.setValueById('fsk-padding', value); + this.checkFskPairsChanged(); + } + + getMultiFskPadding = () => parseInt(this.getValueById('multi-fsk-padding')); + setMultiFskPadding = (value) => { + this.setValueById('multi-fsk-padding', value); + this.checkFskPairsChanged(); + } + + checkFskPairsChanged = () => { + const original = this.originalFskPairs; + const current = this.getFskPairs(); + let changed = true; + if(original.length !== current.length) { + changed = true; + } else { + changed = original.some( + (fsk, fskIndex) => { + return fsk.some((hz, hzIndex) => hz !== original[fskIndex][hzIndex]); + }) + } + if(changed) { + this.originalFskPairs = current; + this.setValueById('fsk-count', current.length.toLocaleString()); + this.drawFrequencySpectrum(); + this.dispatcher.emit('fskPairsChange', {value: current}); + } + } + getFskPairs = () => { + const sampleRate = this.sampleRate; + const fftSize = this.getFftSize(); + const fskPadding = this.getFskPadding(); + const multiFskPadding = this.getMultiFskPadding(); + const frequencyResolution = sampleRate / fftSize; + const fskPairs = []; + const multiFskStep = frequencyResolution * (2 + multiFskPadding) * fskPadding; + const minimumFrequency = this.getMinimumFrequency(); + const maximumFrequency = this.getMaximumFrequency(); + for(let hz = minimumFrequency; hz < maximumFrequency; hz+= multiFskStep) { + const lowHz = hz; + const highHz = hz + frequencyResolution * fskPadding; + if(lowHz < minimumFrequency) continue; + if(highHz > maximumFrequency) break; + fskPairs.push([lowHz, highHz]); + } + return fskPairs; + } + drawFrequencySpectrum = () => { + const ultimateFrequency = this.sampleRate / 2; + const fskPairs = this.getFskPairs(); + const canvas = this.getElement('frequency-spectrum'); + const ctx = canvas.getContext('2d'); + const {height, width} = canvas; + ctx.clearRect(0, 0, width, height); + + // Human Hearing + let x1 = (20/ultimateFrequency) * width; + let x2 = (20000/ultimateFrequency) * width; + ctx.fillStyle = 'hsla(0, 0%, 100%, 20%)'; + ctx.fillRect( + x1, + 0, + x2 - x1, + height + ); + // Telephone + x1 = (300/ultimateFrequency) * width; + x2 = (3400/ultimateFrequency) * width; + ctx.fillStyle = 'hsla(60, 50%, 50%, 20%)'; + ctx.fillRect( + x1, + 0, + x2 - x1, + height + ); + + ctx.lineWith = 1; + const plotHz = hz => { + const percent = (hz / ultimateFrequency); + const hue = Math.floor(percent * 360); + ctx.strokeStyle = `hsl(${hue}, 100%, 50%)`; + const x = percent * width; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + + fskPairs.forEach(fsk => fsk.forEach(plotHz)); + } + +} + +export default FrequencyPanel; \ No newline at end of file diff --git a/Panels/SignalPanel.js b/Panels/SignalPanel.js new file mode 100644 index 0000000..654dbf7 --- /dev/null +++ b/Panels/SignalPanel.js @@ -0,0 +1,50 @@ +import BasePanel from './BasePanel'; + +const clamp = (value, min, max) => Math.max(min, Math.min(value, max)); + +class SignalPanel extends BasePanel { + constructor() { + super('Signal'); + this.openField('Segment Duration'); + this.addInputNumber('segment-duration', 100, {min: 0, max: 1000, eventName: 'segmentDurationChange'}); + this.addText('ms'); + this.closeField(); + + this.addSection('Sending'); + + this.openField('Wave Form'); + this.addDropdown('wave-form', [ + { text: 'Sine Wave', value: 'sine'}, + { text: 'Square Wave', value: 'square'}, + { text: 'Sawtooth Wave', value: 'sawtooth'}, + { text: 'Triangle Wave', value: 'triangle'}, + ], {eventName: 'waveformChange'}); + this.closeField(); + + this.addSection('Receiving'); + + this.openField('Amplitude Threshold'); + this.addInputNumber('amplitude-threshold', 50, {min: 0, max: 100, eventName: 'amplitudeThresholdChange', translation: 'percent'}); + this.addText('%'); + 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(); + }; + + getWaveform = () => this.getValueById('wave-form'); + setWaveform = (value) => this.setValueById('wave-form', value); + + getSegmentDuration = () => parseInt(this.getValueById('segment-duration')); + setSegmentDuration = value => this.setValueById('segment-duration', value); + + getAmplitudeThreshold = () => parseInt(this.getValueById('amplitude-threshold')) / 100; + setAmplitudeThreshold = value => this.setValueById('amplitude-threshold', clamp(value * 100, 0, 100)); + + getSmoothingTimeConstant = () => parseInt(this.getValueById('smoothing-time-constant')) / 100; + setSmoothingTimeConstant = value => this.setValueById('smoothing-time-constant', clamp(value * 100, 0, 100)); +} + +export default SignalPanel; \ No newline at end of file diff --git a/index.html b/index.html index fd20438..63f7dfc 100644 --- a/index.html +++ b/index.html @@ -11,34 +11,12 @@