Files
data-over-audio/index.js
2024-05-03 15:49:27 -04:00

722 lines
23 KiB
JavaScript

var audioContext;
var sendButton;
var textToSend;
var isListeningCheckbox;
var samplesPerBitLabel;
var microphoneStream;
var microphoneNode;
var analyser;
var receivedDataTextarea;
var sentDataTextArea;
var receivedGraph;
var receivedData = [];
var MAX_BITS_DISPLAYED_ON_GRAPH = 9;
var MAX_DATA = 300;
var pauseTimeoutId;
var sampleIntervalId;
// 20 to 20,000 - human
var TEXT_TO_SEND = "Hello World!";
var MINIMUM_FREQUENCY = 5000;
var MAXIMUM_FREQUENCY = 10000;
var FREQUENCY_DURATION = 60;
var FREQUENCY_THRESHOLD_PERCENT = .75;
var FREQUENCY_THRESHOLD = 150;
var FREQUENCY_RESOLUTION_MULTIPLIER = 2;
var SAMPLE_DELAY_MS = 1;
var FFT_POWER = 10;
var LAST_BIT_PERCENT = 0.8;
var SMOOTHING_TIME_CONSTANT = 0;
var frequencyOverTime = [];
var bitStart = [];
var samplesPerBit = [];
var bitSampleCount = 0;
var PAUSE = false;
var PAUSE_AFTER_END = true;
var PACKET_SIZE_BITS = 10;
const packetBits = [];
let packetDataByteCount = -1;
function handleWindowLoad() {
// grab dom elements
sendButton = document.getElementById('send-button');
isListeningCheckbox = document.getElementById('is-listening-checkbox');
receivedDataTextarea = document.getElementById('received-data');
receivedGraph = document.getElementById('received-graph');
textToSend = document.getElementById('text-to-send');
textToSend.value = TEXT_TO_SEND;
sentDataTextArea = document.getElementById('sent-data');
samplesPerBitLabel = document.getElementById('samples-per-bit');
document.getElementById('pause-after-end').checked = PAUSE_AFTER_END;
document.getElementById('pause-after-end').addEventListener('change', event => {
PAUSE_AFTER_END = event.target.checked;
if(!PAUSE_AFTER_END) resumeGraph();
})
document.getElementById('frequency-resolution-multiplier').value = FREQUENCY_RESOLUTION_MULTIPLIER;
document.getElementById('frequency-resolution-multiplier').addEventListener('input', event => {
FREQUENCY_RESOLUTION_MULTIPLIER = parseInt(event.target.value);
showSpeed();
})
document.getElementById('bit-duration-text').addEventListener('input', (event) => {
FREQUENCY_DURATION = parseInt(event.target.value);
bitSampleCount = 0;
samplesPerBit.length = 0;
showSpeed();
});
document.getElementById('max-bits-displayed-on-graph').value= MAX_BITS_DISPLAYED_ON_GRAPH;
document.getElementById('max-bits-displayed-on-graph').addEventListener('input', (event) => {
MAX_BITS_DISPLAYED_ON_GRAPH = parseInt(event.target.value);
})
document.getElementById('bit-duration-text').value = FREQUENCY_DURATION;
document.getElementById('amplitude-threshold-text').value = Math.floor(FREQUENCY_THRESHOLD_PERCENT * 100);
FREQUENCY_THRESHOLD = Math.floor(FREQUENCY_THRESHOLD_PERCENT * 255);
document.getElementById('maximum-frequency').value = MAXIMUM_FREQUENCY;
document.getElementById('minimum-frequency').value = MINIMUM_FREQUENCY;
document.getElementById('last-bit-percent').value = Math.floor(LAST_BIT_PERCENT * 100);
document.getElementById('fft-size-power-text').value = FFT_POWER;
document.getElementById('smoothing-time-constant-text').value = SMOOTHING_TIME_CONSTANT.toFixed(2);
document.getElementById('amplitude-threshold-text').addEventListener('input', (event) => {
FREQUENCY_THRESHOLD_PERCENT = parseInt(event.target.value) / 100;
FREQUENCY_THRESHOLD = Math.floor(FREQUENCY_THRESHOLD_PERCENT * 255);
});
document.getElementById('maximum-frequency').addEventListener('input', (event) => {
MAXIMUM_FREQUENCY = parseInt(event.target.value);
showSpeed();
});
document.getElementById('minimum-frequency').addEventListener('input', (event) => {
MINIMUM_FREQUENCY = parseInt(event.target.value);
showSpeed();
});
document.getElementById('last-bit-percent').addEventListener('input', (event) => {
LAST_BIT_PERCENT = parseInt(event.target.value) / 100;
});
document.getElementById('fft-size-power-text').addEventListener('input', (event) => {
FFT_POWER = parseInt(event.target.value);
if(analyser) analyser.fftSize = 2 ** FFT_POWER;
updateFrequencyResolution();
resetGraphData();
});
updateFrequencyResolution();
document.getElementById('smoothing-time-constant-text').addEventListener('input', event => {
SMOOTHING_TIME_CONSTANT = parseFloat(event.target.value);
if(analyser) analyser.smoothingTimeConstant = SMOOTHING_TIME_CONSTANT;
});
document.getElementById('audio-context-sample-rate').innerText = getAudioContext().sampleRate.toLocaleString();
// wire up events
sendButton.addEventListener('click', handleSendButtonClick);
isListeningCheckbox.addEventListener('click', handleListeningCheckbox);
// textToSend.addEventListener('keypress', handleTextToSendKeypress);
showSpeed();
}
function updateFrequencyResolution() {
const sampleRate = getAudioContext().sampleRate;
const fftSize = 2 ** FFT_POWER;
const frequencyResolution = sampleRate / fftSize;
const frequencyCount = (sampleRate/2) / frequencyResolution;
document.getElementById('frequency-resolution').innerText = frequencyResolution.toFixed(2);
document.getElementById('frequency-count').innerText = frequencyCount.toFixed(2);
showSpeed();
}
function showSpeed() {
const segmentsPerSecond = 1000 / FREQUENCY_DURATION;
const bitsPerSegment = getChannels().length;
const baud = bitsPerSegment * segmentsPerSecond;
const bytes = baud / 8;
document.getElementById('durations-per-second').innerText = segmentsPerSecond.toFixed(2);
document.getElementById('bits-per-duration').innerText = bitsPerSegment;
document.getElementById('data-transfer-speed-bits-per-second').innerText = baud.toFixed(2);
document.getElementById('data-transfer-speed-bytes-per-second').innerText = bytes.toFixed(2);
}
function getFrequency(bit) {
return bit ? MAXIMUM_FREQUENCY : MINIMUM_FREQUENCY;
}
function getChannels() {
var audioContext = getAudioContext();
const sampleRate = audioContext.sampleRate;
const fftSize = 2 ** FFT_POWER;
const frequencyResolution = sampleRate / fftSize;
const channels = [];
const pairStep = frequencyResolution * 2 * FREQUENCY_RESOLUTION_MULTIPLIER;
for(let hz = MINIMUM_FREQUENCY; hz < MAXIMUM_FREQUENCY; hz+= pairStep * 2) {
const low = hz;
const high = hz + frequencyResolution * FREQUENCY_RESOLUTION_MULTIPLIER;
if(low < MINIMUM_FREQUENCY) continue;
if(high > MAXIMUM_FREQUENCY) continue;
channels.push([low, high]);
}
return channels;
}
function logSent(text) {
// display what is being sent
sentDataTextArea.value += text + '\n';
sentDataTextArea.scrollTop = sentDataTextArea.scrollHeight;
}
function sendBits(bits) {
const byteCount = bits.length / 8;
if(bits.length === 0) {
logSent('No bits to send!');
return;
} else if(bits.length % 8 !== 0 || bits.length === 0) {
logSent('Bit count must be divisible by 8.');
return;
} else if(byteCount > (1 << PACKET_SIZE_BITS)) {
logSent(`Can not transfer more than ${(1 << PACKET_SIZE_BITS)} bytes.`);
return;
}
const packetLength = ((byteCount - 1) >>> 0)
.toString(2)
.padStart(PACKET_SIZE_BITS, '0')
.split('')
.map(Number);
bits.unshift(...packetLength);
logSent(bits.join(''));
var audioContext = getAudioContext();
const channels = getChannels();
const oscillators = [];
const channelCount = channels.length;
const currentTime = audioContext.currentTime + 0.1;
// create our oscillators
for(let i = 0; i < channelCount; i++) {
var oscillator = audioContext.createOscillator();
oscillator.connect(audioContext.destination);
oscillators.push(oscillator);
}
// change our channel frequencies for the bit
for(let i = 0; i < bits.length; i++) {
const isHigh = bits[i];
const channel = i % channelCount;
const segment = Math.floor(i / channelCount);
var offset = ((segment * FREQUENCY_DURATION)/1000);
oscillators[channel].frequency.setValueAtTime(
channels[channel][isHigh ? 1 : 0],
currentTime + offset
);
}
// start sending our signal
oscillators.forEach(o => o.start(currentTime));
// silence oscillators when done
for(let i = bits.length; i < bits.length + channelCount; i++) {
const channel = i % channelCount;
const segment = Math.floor(i / channelCount);
const offset = ((segment * FREQUENCY_DURATION) / 1000);
oscillators[channel].stop(currentTime + offset);
}
// start the graph moving again
resumeGraph();
}
function stopGraph() {
PAUSE = true;
if(sampleIntervalId) {
window.clearInterval(sampleIntervalId);
sampleIntervalId = undefined;
}
}
function resumeGraph() {
if(isListeningCheckbox.checked) {
if(PAUSE) {
PAUSE = false;
sampleIntervalId = window.setInterval(collectSample, SAMPLE_DELAY_MS);
resetGraphData();
requestAnimationFrame(drawFrequencyData);
} else {
PAUSE = false;
}
} else {
PAUSE = false;
}
}
function getInteger(start, end, samples) {
let value = 0;
let valueIndex = 0;
for(let i = 0; i < samples.length; i++) {
for(let j = 0; j < samples[i].pairs.length; j++) {
if(b >= start) {
value += getBit(samples[i].pairs[j]) << valueIndex++;
if(b >= end) return bits;
}
b++;
}
}
return bits;
}
function collectSample() {
const time = performance.now();
const frequencies = new Uint8Array(analyser.frequencyBinCount);
const length = audioContext.sampleRate / analyser.fftSize;
let processSegment = false;
const {
hasSignal: hadPriorSignal,
streamStarted: initialStreamStart = -1,
streamEnded: priorStreamEnded = -1,
segmentIndex: priorSegmentIndex = -1
} = frequencyOverTime[0] ?? {}
analyser.getByteFrequencyData(frequencies);
const data = {
time,
frequencies,
length,
streamEnded: priorStreamEnded
};
let hasSignal = false;
data.pairs = getChannels().map(([low, high], i) => {
const lowAmp = frequencies[Math.round(low / length)];
const highAmp = frequencies[Math.round(high / length)];
const isLow = lowAmp > FREQUENCY_THRESHOLD;
const isHigh = highAmp > FREQUENCY_THRESHOLD;
if(isLow || isHigh ) hasSignal = true;
return {
channel: i,
lowHz: low,
highHz: high,
isMissing: !(isHigh || isLow),
isHigh: (isHigh && !isLow) || highAmp > lowAmp
};
});
data.hasSignal = hasSignal;
if(hasSignal) {
if(hadPriorSignal) {
// continued bit stream
data.streamStarted = initialStreamStart;
// proposed end
data.streamEnded = priorStreamEnded;
} else {
// new bit stream
data.streamStarted = time;
// clear last packet
packetBits.length = 0;
packetDataByteCount = 0;
}
// number of bit in the stream
const segmentIndex = data.segmentIndex = Math.floor((time - initialStreamStart) / FREQUENCY_DURATION);
if(priorSegmentIndex !== segmentIndex && priorSegmentIndex !== -1) {
processSegment = true;
}
} else {
data.segmentIndex = -1;
if(hadPriorSignal) {
// just stopped
data.streamStarted = -1;
data.streamEnded = -1;
// update all prior values with stream end if they don't have one
frequencyOverTime
.filter(fov => fov.streamStarted === initialStreamStart)
.filter(fov => fov.streamEnded === -1)
.forEach(fov => {
fov.streamEnded === time
});
processSegment = true;
if(PAUSE_AFTER_END && !pauseTimeoutId) {
pauseTimeoutId = window.setTimeout(() => {
pauseTimeoutId = undefined;
if(PAUSE_AFTER_END) stopGraph();
}, FREQUENCY_DURATION * 1.5);
}
} else {
// continued stopping (or never started)
data.streamEnded = -1;
}
}
frequencyOverTime.unshift(data);
if(processSegment) processSegmentReceived();
truncateGraphData();
}
function evaluateBit(samples, segment, channel) {
const started = samples.find(s => s.streamStarted > 0).streamStarted;
const bitSamples = samples.filter(sample => {
return sample.time >= started + (segment * FREQUENCY_DURATION) &&
sample.time < started + ((segment + 1) * FREQUENCY_DURATION)
}).map(samples => samples.pairs[channel])
.reduce((bitSamples, { isHigh, isMissing }) => {
bitSamples.total++;
if(isHigh) bitSamples.highCount++;
}, {highCount: 0, total: 0});
return bitSamples.highCount >= bitSamples.total / 2 ? 1 : 0;
}
function processSegmentReceived() {
const {
segmentIndex,
streamStarted,
pairs: {
length: channelCount
}
} = frequencyOverTime[0];
const bits = frequencyOverTime.filter(f =>
f.segmentIndex === segmentIndex &&
f.streamStarted === streamStarted
);
const bitEnded = bits[0].time;
const bitStarted = streamStarted + (FREQUENCY_DURATION * segmentIndex);
const bitDuration = bitEnded - bitStarted;
// if(bitDuration < FREQUENCY_DURATION * LAST_BIT_PERCENT) {
// return;
// }
const channels = new Array(channelCount).fill(0).map(() => ({isHigh: 0, isLow: 0, isMissing: 0}));
bits.forEach(({pairs}) => {
pairs.forEach(({ isHigh, isMissing }, i) => {
if(isHigh) channels[i].isHigh ++;
// else if(isMissing) channels[i].isMissing ++;
else channels[i].isLow++;
})
});
const bitValues = channels.map(({isHigh, isLow, isMissing}) => {
if(isMissing > isHigh + isLow) return '.';
return isHigh > isLow ? '1' : '0';
});
packetBits.push(...bitValues);
if(packetBits.length >= 10) {
// we can evaluate how many bytes are comming
const dataLengthIndex = Math.floor(10 / channelCount);
if(dataLengthIndex === segmentIndex) {
// we just got the bits we needed
packetDataByteCount = 1 + packetBits
.slice(0, PACKET_SIZE_BITS)
.reduce((value, bit) => (value << 1) | bit);
// let's get the end time
const totalBits = (packetDataByteCount * 8) + PACKET_SIZE_BITS;
const segments = Math.ceil(totalBits / channelCount);
const duration = segments * FREQUENCY_DURATION;
const streamEnded = streamStarted + duration;
console.log({
tenBitNum: packetBits
.slice(0, PACKET_SIZE_BITS).join(''),
packetDataByteCount,
PACKET_SIZE_BITS,
totalBits,
segments,
streamStarted,
duration,
streamEnded
});
// update everyones proposed end time
frequencyOverTime
.filter(fot => fot.streamStarted === streamStarted)
.forEach(fot => {
fot.streamEnded = streamEnded
});
}
// remove phantom bits
const totalBits = (packetDataByteCount * 8) + PACKET_SIZE_BITS;
if(packetBits.length > totalBits) {
const excess = packetBits.length % totalBits;
packetBits.length = totalBits;
bitValues.length = bitValues.length - excess;
}
}
received(bitValues.join('') + '\n');
}
function resetGraphData() {
frequencyOverTime.length = 0;
bitStart.length = 0;
}
function truncateGraphData() {
const duration = FREQUENCY_DURATION * MAX_BITS_DISPLAYED_ON_GRAPH;
const now = performance.now();
let length = frequencyOverTime.length;
while(length !== 0) {
const time = frequencyOverTime[length-1].time;
if(now - time > duration) length--;
else break;
}
if(length !== frequencyOverTime.length) {
frequencyOverTime.length = length;
bitStart.length = length;
}
}
function getAudioContext() {
if(!audioContext) {
audioContext = new (window.AudioContext || webkitAudioContext)();
}
if(audioContext.state === 'suspended') {
audioContext.resume();
}
return audioContext;
}
function textToBits(text) {
const bits = [];
for(let i = 0; i < text.length; i++) {
// const unicode = text.codePointAt(i).toString(2).padStart(16, '0');
const ascii = text[i].charCodeAt(0).toString(2).padStart(8, '0');
bits.push(ascii);
}
return bits.join('').split('').map(Number);
}
function handleSendButtonClick() {
receivedDataTextarea.value = '';
sentDataTextArea.value = '';
const text = document.getElementById('text-to-send').value;
sendBits(textToBits(text));
}
function handleListeningCheckbox(e) {
stopGraph();
var audioContext = getAudioContext();
function handleMicrophoneOn(stream) {
microphoneStream = stream;
microphoneNode = audioContext.createMediaStreamSource(stream);
analyser = audioContext.createAnalyser();
analyser.smoothingTimeConstant = SMOOTHING_TIME_CONSTANT;
analyser.fftSize = 2 ** FFT_POWER;
microphoneNode.connect(analyser);
resumeGraph();
}
function handleMicrophoneError(error) {
console.error('Microphone Error', error);
}
if(e.target.checked) {
navigator.mediaDevices
.getUserMedia({
audio: {
mandatory: {
autoGainControl: false,
echoCancellation: false,
noiseSuppression: false,
suppressLocalAudioPlayback: false,
voiceIsolation: false
},
optional: []
}
})
.then(handleMicrophoneOn)
.catch(handleMicrophoneError)
} else {
if(microphoneStream) {
microphoneStream.getTracks().forEach(track => track.stop());
microphoneStream = undefined;
}
if(analyser && microphoneNode) {
try {
analyser.disconnect(microphoneNode);
} catch(e) {
}
microphoneNode = undefined;
analyser = undefined;
}
}
}
function received(value) {
receivedDataTextarea.value += value;
receivedDataTextarea.scrollTop = receivedDataTextarea.scrollHeight;
}
let bitStarted;
let lastBitStarted;
let bitEnded;
let bitHighStrength = [];
let bitLowStrength = [];
let lastSegmentIndex = 0;
function canHear(hz, {frequencies, length}) {
var i = Math.round(hz / length);
return frequencies[i] > FREQUENCY_THRESHOLD;
}
function amplitude(hz, {frequencies, length}) {
var i = Math.round(hz / length);
return frequencies[i];
}
const sum = (total, value) => total + value;
// function evaluateBit(highBits, lowBits) {
// let highCount = highBits.reduce(
// (count, highAmplitude, i) =>
// count += highAmplitude > lowBits[i] ? 1 : 0
// , 0
// );
// return highCount >= (highBits.length / 2) ? '1' : '0';
// }
function avgLabel(array) {
const values = array.filter(v => v > 0);
if(values.length === 0) return 'N/A';
return (values.reduce((t, v) => t + v, 0) / values.length).toFixed(2)
}
function drawBitDurationLines(ctx, color) {
const { width, height } = receivedGraph;
const newest = frequencyOverTime[0].time;
const duration = FREQUENCY_DURATION * MAX_BITS_DISPLAYED_ON_GRAPH;
const streamTimes = frequencyOverTime.filter(({
streamStarted
}) => {
return streamStarted !== -1
}).reduce((unique, {
streamStarted,
streamEnded = newest
}) => {
if(unique.every(u => u.streamStarted != streamStarted)) {
unique.push({streamStarted, streamEnded})
}
return unique;
}, []);
ctx.strokeStyle = color;
streamTimes.forEach(({ streamStarted, streamEnded = newest}) => {
for(let time = streamStarted; time < streamEnded; time += FREQUENCY_DURATION) {
if(newest - time > duration) continue;
const x = ((newest - time) / duration) * width;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
// write end as well
const x = ((newest - streamEnded) / duration) * width;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
});
}
function drawBitStart(ctx, color) {
const { width, height } = receivedGraph;
const newest = frequencyOverTime[0].time;
const duration = FREQUENCY_DURATION * MAX_BITS_DISPLAYED_ON_GRAPH;
ctx.strokeStyle = color;
for(let i = 0; i < bitStart.length; i++) {
if(!bitStart[i]) continue;
const {time} = frequencyOverTime[i];
if(newest - time > duration) continue;
const x = ((newest - time) / duration) * width;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
}
function hzAmplitude(hz, length, frequencies) {
var index = Math.round(hz / length);
return frequencies[index];
}
function getPercentY(percent) {
const { height } = receivedGraph;
return (1 - percent) * height;
}
function drawFrequencyLineGraph(ctx, hz, color) {
const { width, height } = receivedGraph;
const newest = frequencyOverTime[0].time;
const duration = FREQUENCY_DURATION * MAX_BITS_DISPLAYED_ON_GRAPH;
ctx.strokeStyle = color;
ctx.beginPath();
for(let i = 0; i < frequencyOverTime.length; i++) {
const {frequencies, time, length} = frequencyOverTime[i];
const x = getTimeX(time, newest);
if(x === -1) continue;
const amplitude = hzAmplitude(hz, length, frequencies);
const y = getPercentY(amplitude / MAX_DATA);
if(i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
function drawFrequencyDots(ctx, hz, color) {
const newest = frequencyOverTime[0].time;
const radius = 2;
const border = 0.5;
ctx.fillStyle = color;
ctx.strokeStyle = 'white';
ctx.lineWidth = border;
const fullCircle = 2 * Math.PI;
for(let i = 0; i < frequencyOverTime.length; i++) {
const {frequencies, time, length} = frequencyOverTime[i];
const x = getTimeX(time, newest);
if(x === -1) continue;
const amplitude = hzAmplitude(hz, length, frequencies);
const y = getPercentY(amplitude / MAX_DATA);
ctx.beginPath();
ctx.arc(x, y, radius, 0, fullCircle);
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, radius + border, 0, fullCircle);
ctx.stroke();
}
}
function getTimeX(time, newest) {
const { width } = receivedGraph;
const duration = FREQUENCY_DURATION * MAX_BITS_DISPLAYED_ON_GRAPH;
if(newest - time > duration) return -1;
return ((newest - time) / duration) * width;
}
function drawFrequencyData() {
if(PAUSE) return;
if(frequencyOverTime.length === 0) {
requestAnimationFrame(drawFrequencyData);
return;
}
const ctx = receivedGraph.getContext('2d');
const { width, height } = receivedGraph;
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, width, height);
const thresholdY = (1 - (FREQUENCY_THRESHOLD/MAX_DATA)) * height;
ctx.strokeStyle = 'grey';
ctx.beginPath();
ctx.moveTo(0, thresholdY);
ctx.lineTo(width, thresholdY);
ctx.stroke();
drawBitDurationLines(ctx, 'yellow');
drawBitStart(ctx, 'green');
const frequencies = getChannels();
frequencies.forEach(([low, high], i) => {
if(i >= frequencies.length - 1) {
drawFrequencyLineGraph(ctx, high, 'pink');
drawFrequencyLineGraph(ctx, low, 'cyan');
} else {
drawFrequencyLineGraph(ctx, high, 'rgba(255, 0, 0, .5)');
drawFrequencyLineGraph(ctx, low, 'rgba(0, 0, 255, .5)');
}
});
requestAnimationFrame(drawFrequencyData);
}
function drawReceivedData() {
const ctx = receivedGraph.getContext('2d');
const { width, height } = receivedGraph;
const segmentWidth = (1 / MAX_BITS_DISPLAYED_ON_GRAPH) * width;
ctx.clearRect(0, 0, width, height);
const sorted = receivedData.slice().sort((a, b) => a - b);
const min = sorted[0];
const max = sorted[sorted.length - 1];
const range = max - min;
ctx.beginPath();
for(let i = 0; i < MAX_BITS_DISPLAYED_ON_GRAPH && i < receivedData.length; i++) {
const value = receivedData[i];
const y = (1-(value / range)) * height;
if(i === 0) {
ctx.moveTo(0, y);
} else {
ctx.lineTo(segmentWidth * i, y)
}
}
ctx.stroke();
}
window.addEventListener('load', handleWindowLoad);