working SSB!

This commit is contained in:
birb 2025-04-13 19:58:45 -05:00
parent fe12d2e6a3
commit d28399b51a
No known key found for this signature in database
GPG Key ID: BFB779869FEE99D2
13 changed files with 1133 additions and 36557 deletions

45
package-lock.json generated
View File

@ -10,9 +10,11 @@
"dependencies": {
"buffer": "^6.0.3",
"extend": "^3.0.2",
"file-saver": "^2.0.5",
"opus-decoder": "^0.7.7",
"readable-stream": "^4.7.0",
"socket.io-client": "^4.8.1"
"socket.io-client": "^4.8.1",
"tcadif": "^2.2.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
@ -1290,6 +1292,18 @@
"node": ">= 0.6"
}
},
"node_modules/csv-parse": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
"license": "MIT"
},
"node_modules/csv-stringify": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.5.2.tgz",
"integrity": "sha512-RFPahj0sXcmUyjrObAK+DOWtMvMIFV328n4qZJhgX3x2RqkQgOTU2mCUmiFR0CzM6AzChlRSUErjiJeEt8BaQA==",
"license": "MIT"
},
"node_modules/daisyui": {
"version": "5.0.17",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.17.tgz",
@ -1480,6 +1494,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -1819,6 +1839,15 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -2167,6 +2196,20 @@
"node": ">=6"
}
},
"node_modules/tcadif": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tcadif/-/tcadif-2.2.0.tgz",
"integrity": "sha512-20UPYtzjBH7NagwNzBtBwJ7PIHnZwjJSPx9UhxGfkzkgUC+iAWRZzH3pJW/7nut37r3ofIMDQtw+UV4BQigylA==",
"license": "MIT",
"dependencies": {
"csv-parse": "^5.5.3",
"csv-stringify": "^6.4.5",
"moment": "^2.29.4"
},
"bin": {
"tcadif": "bin/tcadif.js"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",

View File

@ -22,8 +22,10 @@
"dependencies": {
"buffer": "^6.0.3",
"extend": "^3.0.2",
"file-saver": "^2.0.5",
"opus-decoder": "^0.7.7",
"readable-stream": "^4.7.0",
"socket.io-client": "^4.8.1"
"socket.io-client": "^4.8.1",
"tcadif": "^2.2.0"
}
}

View File

@ -4,4 +4,4 @@
@theme {
--font-sans: "Inter", system-ui, "Roboto", sans-serif;
--font-mono: "IBM Plex Mono", SFMono-Regular, monospace;
}
}

View File

@ -17,6 +17,7 @@
rel="stylesheet"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>remoteham</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

530
src/lib/spectrogram.js Normal file
View File

@ -0,0 +1,530 @@
// acquired from https://www.arc.id.au/Spectrogram.html
/*=================================================================
Filename: Spectrogram-2v01.js
Rev: 2
By: Dr A.R.Collins
Description: JavaScript graphics functions to draw Spectrograms.
Date Description By
-------|----------------------------------------------------|---
12Nov18 First beta ARC
17Nov18 Added offset into data buffer ARC
08May19 this.imageURL URL added
bugfix: fixed isNaN test
Changed sgStart, sgStop to start, stop
Added options object to constructors ARC
10May19 Enabled Left to Right as well as Top to Bottom ARC
11May19 Added RasterscanSVG ARC
12May19 Added blankline for horizontal raster scans ARC
13May19 Eliminated unnecessary putImageData ARC
14May19 Removed toDataURL, not used drawImage is better
bugfix: SVG RHC names swapped ARC
02Jun19 bugfix: startOfs not honored in horizontalNewLine ARC
03Jun19 Flipped the SVG and RHC names for waterfalls ARC
04Jun19 Unflip SVG and RHC for horizontal mode ARC
Swap "SVG" & "RHC" strings to match fn names ARC
05Jun19 bugfix: WaterfallSVG scrolling wrong way ARC
10Jun19 bugfix: support lineRate=0 for static display
bugfix: ipBufPtr must be a ptr to a ptr ARC
11Jun19 Make ipBuffers an Array of Arrays, if lineRate=0
use all buffers else use only ipBuffer[0] ARC
13Jun19 Use Waterfall and Rasterscan plus direction
Use Boolean rather than string compare ARC
16Jun19 Use const and let ARC
20Jun19 Change order of parameters ARC
21Jun19 Add setLineRate method ARC
06Jul19 Released as Rev 1v00 ARC
25Jul21 Refactor using class, arrow functions etc
Added RasterImage object
Use object.buffer as input not array of arrays ARC
25Jul21 Released as Rev 2v00 ARC
=================================================================*/
export var Waterfall, Rasterscan, RasterImage;
(function(){
class Spectrogram {
constructor(ipObj, w, h, sgMode, rhc, vert, options) {
const opt = (typeof options === 'object')? options: {}; // avoid undeclared object errors
const pxPerLine = w || 200;
const lines = h || 200;
const lineBuf = new ArrayBuffer(pxPerLine * 4); // 1 line
const lineBuf8 = new Uint8ClampedArray(lineBuf);
const lineImgData = new ImageData(lineBuf8, pxPerLine, 1); // 1 line of canvas pixels
const blankBuf = new ArrayBuffer(pxPerLine * 4); // 1 line
const blankBuf8 = new Uint8ClampedArray(blankBuf);
const blankImgData = new ImageData(blankBuf8, pxPerLine, 1); // 1 line of canvas pixels
const clearBuf = new ArrayBuffer(pxPerLine * lines * 4); // fills with 0s ie. rgba 0,0,0,0 = transparent
const clearBuf8 = new Uint8ClampedArray(clearBuf);
let offScreenCtx; // offscreen canvas drawing context
let clearImgData;
let lineRate = 30; // requested line rate for dynamic waterfalls
let interval = 0; // msec
let startOfs = 0;
let nextLine = 0;
let timerID = null;
let running = false;
let sgTime = 0;
let sgStartTime = 0;
// Matlab Jet ref: stackoverflow.com grayscale-to-red-green-blue-matlab-jet-color-scale
let colMap = [[ 0, 0, 128, 255], [ 0, 0, 131, 255], [ 0, 0, 135, 255], [ 0, 0, 139, 255],
[ 0, 0, 143, 255], [ 0, 0, 147, 255], [ 0, 0, 151, 255], [ 0, 0, 155, 255],
[ 0, 0, 159, 255], [ 0, 0, 163, 255], [ 0, 0, 167, 255], [ 0, 0, 171, 255],
[ 0, 0, 175, 255], [ 0, 0, 179, 255], [ 0, 0, 183, 255], [ 0, 0, 187, 255],
[ 0, 0, 191, 255], [ 0, 0, 195, 255], [ 0, 0, 199, 255], [ 0, 0, 203, 255],
[ 0, 0, 207, 255], [ 0, 0, 211, 255], [ 0, 0, 215, 255], [ 0, 0, 219, 255],
[ 0, 0, 223, 255], [ 0, 0, 227, 255], [ 0, 0, 231, 255], [ 0, 0, 235, 255],
[ 0, 0, 239, 255], [ 0, 0, 243, 255], [ 0, 0, 247, 255], [ 0, 0, 251, 255],
[ 0, 0, 255, 255], [ 0, 4, 255, 255], [ 0, 8, 255, 255], [ 0, 12, 255, 255],
[ 0, 16, 255, 255], [ 0, 20, 255, 255], [ 0, 24, 255, 255], [ 0, 28, 255, 255],
[ 0, 32, 255, 255], [ 0, 36, 255, 255], [ 0, 40, 255, 255], [ 0, 44, 255, 255],
[ 0, 48, 255, 255], [ 0, 52, 255, 255], [ 0, 56, 255, 255], [ 0, 60, 255, 255],
[ 0, 64, 255, 255], [ 0, 68, 255, 255], [ 0, 72, 255, 255], [ 0, 76, 255, 255],
[ 0, 80, 255, 255], [ 0, 84, 255, 255], [ 0, 88, 255, 255], [ 0, 92, 255, 255],
[ 0, 96, 255, 255], [ 0, 100, 255, 255], [ 0, 104, 255, 255], [ 0, 108, 255, 255],
[ 0, 112, 255, 255], [ 0, 116, 255, 255], [ 0, 120, 255, 255], [ 0, 124, 255, 255],
[ 0, 128, 255, 255], [ 0, 131, 255, 255], [ 0, 135, 255, 255], [ 0, 139, 255, 255],
[ 0, 143, 255, 255], [ 0, 147, 255, 255], [ 0, 151, 255, 255], [ 0, 155, 255, 255],
[ 0, 159, 255, 255], [ 0, 163, 255, 255], [ 0, 167, 255, 255], [ 0, 171, 255, 255],
[ 0, 175, 255, 255], [ 0, 179, 255, 255], [ 0, 183, 255, 255], [ 0, 187, 255, 255],
[ 0, 191, 255, 255], [ 0, 195, 255, 255], [ 0, 199, 255, 255], [ 0, 203, 255, 255],
[ 0, 207, 255, 255], [ 0, 211, 255, 255], [ 0, 215, 255, 255], [ 0, 219, 255, 255],
[ 0, 223, 255, 255], [ 0, 227, 255, 255], [ 0, 231, 255, 255], [ 0, 235, 255, 255],
[ 0, 239, 255, 255], [ 0, 243, 255, 255], [ 0, 247, 255, 255], [ 0, 251, 255, 255],
[ 0, 255, 255, 255], [ 4, 255, 251, 255], [ 8, 255, 247, 255], [ 12, 255, 243, 255],
[ 16, 255, 239, 255], [ 20, 255, 235, 255], [ 24, 255, 231, 255], [ 28, 255, 227, 255],
[ 32, 255, 223, 255], [ 36, 255, 219, 255], [ 40, 255, 215, 255], [ 44, 255, 211, 255],
[ 48, 255, 207, 255], [ 52, 255, 203, 255], [ 56, 255, 199, 255], [ 60, 255, 195, 255],
[ 64, 255, 191, 255], [ 68, 255, 187, 255], [ 72, 255, 183, 255], [ 76, 255, 179, 255],
[ 80, 255, 175, 255], [ 84, 255, 171, 255], [ 88, 255, 167, 255], [ 92, 255, 163, 255],
[ 96, 255, 159, 255], [100, 255, 155, 255], [104, 255, 151, 255], [108, 255, 147, 255],
[112, 255, 143, 255], [116, 255, 139, 255], [120, 255, 135, 255], [124, 255, 131, 255],
[128, 255, 128, 255], [131, 255, 124, 255], [135, 255, 120, 255], [139, 255, 116, 255],
[143, 255, 112, 255], [147, 255, 108, 255], [151, 255, 104, 255], [155, 255, 100, 255],
[159, 255, 96, 255], [163, 255, 92, 255], [167, 255, 88, 255], [171, 255, 84, 255],
[175, 255, 80, 255], [179, 255, 76, 255], [183, 255, 72, 255], [187, 255, 68, 255],
[191, 255, 64, 255], [195, 255, 60, 255], [199, 255, 56, 255], [203, 255, 52, 255],
[207, 255, 48, 255], [211, 255, 44, 255], [215, 255, 40, 255], [219, 255, 36, 255],
[223, 255, 32, 255], [227, 255, 28, 255], [231, 255, 24, 255], [235, 255, 20, 255],
[239, 255, 16, 255], [243, 255, 12, 255], [247, 255, 8, 255], [251, 255, 4, 255],
[255, 255, 0, 255], [255, 251, 0, 255], [255, 247, 0, 255], [255, 243, 0, 255],
[255, 239, 0, 255], [255, 235, 0, 255], [255, 231, 0, 255], [255, 227, 0, 255],
[255, 223, 0, 255], [255, 219, 0, 255], [255, 215, 0, 255], [255, 211, 0, 255],
[255, 207, 0, 255], [255, 203, 0, 255], [255, 199, 0, 255], [255, 195, 0, 255],
[255, 191, 0, 255], [255, 187, 0, 255], [255, 183, 0, 255], [255, 179, 0, 255],
[255, 175, 0, 255], [255, 171, 0, 255], [255, 167, 0, 255], [255, 163, 0, 255],
[255, 159, 0, 255], [255, 155, 0, 255], [255, 151, 0, 255], [255, 147, 0, 255],
[255, 143, 0, 255], [255, 139, 0, 255], [255, 135, 0, 255], [255, 131, 0, 255],
[255, 128, 0, 255], [255, 124, 0, 255], [255, 120, 0, 255], [255, 116, 0, 255],
[255, 112, 0, 255], [255, 108, 0, 255], [255, 104, 0, 255], [255, 100, 0, 255],
[255, 96, 0, 255], [255, 92, 0, 255], [255, 88, 0, 255], [255, 84, 0, 255],
[255, 80, 0, 255], [255, 76, 0, 255], [255, 72, 0, 255], [255, 68, 0, 255],
[255, 64, 0, 255], [255, 60, 0, 255], [255, 56, 0, 255], [255, 52, 0, 255],
[255, 48, 0, 255], [255, 44, 0, 255], [255, 40, 0, 255], [255, 36, 0, 255],
[255, 32, 0, 255], [255, 28, 0, 255], [255, 24, 0, 255], [255, 20, 0, 255],
[255, 16, 0, 255], [255, 12, 0, 255], [255, 8, 0, 255], [255, 4, 0, 255],
[255, 0, 0, 255], [251, 0, 0, 255], [247, 0, 0, 255], [243, 0, 0, 255],
[239, 0, 0, 255], [235, 0, 0, 255], [231, 0, 0, 255], [227, 0, 0, 255],
[223, 0, 0, 255], [219, 0, 0, 255], [215, 0, 0, 255], [211, 0, 0, 255],
[207, 0, 0, 255], [203, 0, 0, 255], [199, 0, 0, 255], [195, 0, 0, 255],
[191, 0, 0, 255], [187, 0, 0, 255], [183, 0, 0, 255], [179, 0, 0, 255],
[175, 0, 0, 255], [171, 0, 0, 255], [167, 0, 0, 255], [163, 0, 0, 255],
[159, 0, 0, 255], [155, 0, 0, 255], [151, 0, 0, 255], [147, 0, 0, 255],
[143, 0, 0, 255], [139, 0, 0, 255], [135, 0, 0, 255], [131, 0, 0, 255],
[ 0, 0, 0, 0]];
const incrLine = ()=>
{
if ((vert && !rhc) || (!vert && rhc))
{
nextLine++;
if (nextLine >= lines)
{
nextLine = 0;
}
}
else
{
nextLine--;
if (nextLine < 0)
{
nextLine = lines-1;
}
}
}
const updateWaterfall = ()=> // update dynamic waterfalls at a fixed rate
{
let sgDiff;
// grab latest line of data, write it to off screen buffer, inc 'nextLine'
this.newLine();
// loop to write data data at the desired rate, data is being updated asynchronously
// ref for accurate timeout: http://www.sitepoint.com/creating-accurate-timers-in-javascript
sgTime += interval;
sgDiff = (Date.now() - sgStartTime) - sgTime;
if (running)
{
timerID = setTimeout(updateWaterfall, interval - sgDiff);
}
}
const setProperty = (propertyName, value)=>
{
if ((typeof propertyName !== "string")||(value === undefined)) // null is OK, forces default
{
return;
}
switch (propertyName.toLowerCase())
{
case "linerate":
this.setLineRate(value); // setLine does checks for number etc
break;
case "startbin":
if (!isNaN(value) && value > 0)
{
startOfs = value;
}
break;
case "onscreenparentid":
if (typeof value === "string" && document.getElementById(value))
{
demoCvsId = value;
}
break;
case "colormap":
if (Array.isArray(value) && Array.isArray(value[0]) && value[0].length == 4)
{
colMap = value; // value must be an array of 4 element arrays to get here
if (colMap.length<256) // fill out the remaining colors with last color
{
for (let i=colMap.length; i<256; i++)
{
colMap[i] = colMap[colMap.length-1];
}
}
}
break;
default:
break;
}
}
const verticalNewLine = ()=>
{
let tmpImgData, ipBuf8;
if (sgMode == "WF")
{
if (rhc)
{
// shift the current display down 1 line, oldest line drops off
tmpImgData = offScreenCtx.getImageData(0, 0, pxPerLine, lines-1);
offScreenCtx.putImageData(tmpImgData, 0, 1);
}
else
{
// shift the current display up 1 line, oldest line drops off
tmpImgData = offScreenCtx.getImageData(0, 1, pxPerLine, lines-1);
offScreenCtx.putImageData(tmpImgData, 0, 0);
}
}
ipBuf8 = Uint8ClampedArray.from(ipObj.buffer);
for (let sigVal, rgba, opIdx = 0, ipIdx = startOfs; ipIdx < pxPerLine+startOfs; opIdx += 4, ipIdx++)
{
sigVal = ipBuf8[ipIdx] || 0; // if input line too short add zeros
rgba = colMap[sigVal]; // array of rgba values
// byte reverse so number aa bb gg rr
lineBuf8[opIdx] = rgba[0]; // red
lineBuf8[opIdx+1] = rgba[1]; // green
lineBuf8[opIdx+2] = rgba[2]; // blue
lineBuf8[opIdx+3] = rgba[3]; // alpha
}
offScreenCtx.putImageData(lineImgData, 0, nextLine);
if (sgMode === "RS")
{
incrLine();
// if not static draw a white line in front of the current line to indicate new data point
if (lineRate)
{
offScreenCtx.putImageData(blankImgData, 0, nextLine);
}
}
}
const horizontalNewLine = ()=>
{
let tmpImgData, ipBuf8;
if (sgMode == "WF")
{
if (rhc)
{
// shift the current display right 1 line, oldest line drops off
tmpImgData = offScreenCtx.getImageData(0, 0, lines-1, pxPerLine);
offScreenCtx.putImageData(tmpImgData, 1, 0);
}
else
{
// shift the current display left 1 line, oldest line drops off
tmpImgData = offScreenCtx.getImageData(1, 0, lines-1, pxPerLine);
offScreenCtx.putImageData(tmpImgData, 0, 0);
}
}
// refresh the page image (it was just shifted)
const pageImgData = offScreenCtx.getImageData(0, 0, lines, pxPerLine);
if (ipObj.buffer.constructor !== Uint8Array)
{
ipBuf8 = Uint8ClampedArray.from(ipObj.buffer); // clamp input values to 0..255 range
}
else
{
ipBuf8 = ipObj.buffer; // conversion already done
}
for (let sigVal, rgba, opIdx, ipIdx=0; ipIdx < pxPerLine; ipIdx++)
{
sigVal = ipBuf8[ipIdx+startOfs] || 0; // if input line too short add zeros
rgba = colMap[sigVal]; // array of rgba values
opIdx = 4*((pxPerLine-ipIdx-1)*lines+nextLine);
// byte reverse so number aa bb gg rr
pageImgData.data[opIdx] = rgba[0]; // red
pageImgData.data[opIdx+1] = rgba[1]; // green
pageImgData.data[opIdx+2] = rgba[2]; // blue
pageImgData.data[opIdx+3] = rgba[3]; // alpha
}
if (sgMode === "RS")
{
incrLine();
// if not draw a white line in front of the current line to indicate new data point
if (lineRate)
{
for (let j=0; j < pxPerLine; j++)
{
let opIdx;
if (rhc)
{
opIdx = 4*(j*lines+nextLine);
}
else
{
opIdx = 4*((pxPerLine-j-1)*lines+nextLine);
}
// byte reverse so number aa bb gg rr
pageImgData.data[opIdx] = 255; // red
pageImgData.data[opIdx+1] = 255; // green
pageImgData.data[opIdx+2] = 255; // blue
pageImgData.data[opIdx+3] = 255; // alpha
}
}
}
offScreenCtx.putImageData(pageImgData, 0, 0);
}
const createOffScreenCanvas = ()=>
{
const cvs = document.createElement("canvas");
if (vert)
{
cvs.setAttribute('width', pxPerLine); // reset canvas pixels width
cvs.setAttribute('height', lines); // don't use style for this
clearImgData = new ImageData(clearBuf8, pxPerLine, lines);
}
else // data written in columns
{
cvs.setAttribute('width', lines); // reset canvas pixels width
cvs.setAttribute('height', pxPerLine); // don't use style for this
clearImgData = new ImageData(clearBuf8, lines, pxPerLine);
}
offScreenCtx = cvs.getContext("2d");
return cvs;
}
// ===== now make the exposed properties and methods ===============
this.newLine = (vert)? verticalNewLine: horizontalNewLine; // function pointers
this.offScreenCvs = createOffScreenCanvas();
this.setLineRate = function sgSetLineRate(newRate)
{
if (isNaN(newRate) || newRate > 50 || newRate < 0)
{
console.error("invalid line rate [0 <= lineRate < 50 lines/sec]");
// don't change the lineRate;
}
else if (newRate === 0) // static (one pass) raster
{
lineRate = 0;
}
else
{
lineRate = newRate;
interval = 1000/lineRate; // msec
}
};
this.clear = function()
{
offScreenCtx.putImageData(clearImgData, 0, 0);
};
this.start = function()
{
sgStartTime = Date.now();
sgTime = 0;
running = true;
updateWaterfall(); // start the update loop
};
this.stop = function()
{
running = false;
if (timerID)
{
clearTimeout(timerID);
}
// reset where the next line is to be written
if (sgMode === "RS")
{
if (vert)
{
nextLine = (rhc)? lines-1 : 0;
}
else
{
nextLine = (rhc)? 0 : lines-1;
}
}
else // WF
{
nextLine = (rhc)? 0 : lines-1;
}
};
//===== set all the options ================
for (let prop in opt)
{
// check that this is opt's own property, not inherited from prototype
if (opt.hasOwnProperty(prop))
{
setProperty(prop, opt[prop]);
}
}
// make a white line, it will show the input line for RS displays
blankBuf8.fill(255);
// make a full canvas of the color map 0 values
for (let i=0; i<pxPerLine*lines*4; i+=4)
{
// byte reverse so number aa bb gg rr
clearBuf8[i] = colMap[0][0]; // red
clearBuf8[i+1] = colMap[0][1]; // green
clearBuf8[i+2] = colMap[0][2]; // blue
clearBuf8[i+3] = colMap[0][3]; // alpha
}
// for diagnostics only
if (typeof(demoCvsId) == "string")
{
document.getElementById(demoCvsId).appendChild(this.offScreenCvs);
}
// initialize the direction and first line position
this.stop();
// everything is set
// if dynamic, wait for the start or newLine methods to be called
}
}
Waterfall = class extends Spectrogram
{
constructor(ipObj, w, h, dir, options) // ipObj = {buffer: [..]}
{
var direction = (typeof(dir) === "string")? dir.toLowerCase() : "down";
switch (direction)
{
case "up":
super(ipObj, w, h, "WF", false, true, options);
break;
case "down":
default:
super(ipObj, w, h, "WF", true, true, options);
break;
case "left":
super(ipObj, w, h, "WF", false, false, options);
break;
case "right":
super(ipObj, w, h, "WF", true, false, options);
break;
}
}
}
Rasterscan = class extends Spectrogram
{
constructor(ipObj, w, h, dir, options) // ipObj = {buffer: [..]}
{
const direction = (typeof(dir) === "string")? dir.toLowerCase() : "down";
switch (direction)
{
case "up":
super(ipObj, w, h, "RS", true, true, options);
break;
case "down":
default:
super(ipObj, w, h, "RS", false, true, options);
break;
case "left":
super(ipObj, w, h, "RS", false, false, options);
break;
case "right":
super(ipObj, w, h, "RS", true, false, options);
break;
}
}
}
RasterImage = class
{
constructor(dataBuf, cols, rows, options={}) // dataBuf = Array[rows][cols]
{
const ipObj = {buffer:null};
const dirs = ["up", "down", "left"];
let direction = "down";
let dirLC;
if (options.hasOwnProperty("dir") && typeof(options.dir)==="string")
{
dirLC = options.dir.toLowerCase();
}
else if (options.hasOwnProperty("direction") && typeof(options.direction)==="string")
{
dirLC = options.direction.toLowerCase();
}
if (dirLC && dirs.includes(dirLC))
direction = dirLC;
// dataBuf values are each an index (0..255) into a colorMap
// Each of 256 colorMap entries holds the 4 values RGBA each (0..255) of a color
// force static image
options.lineRate = 0;
const raster = new Rasterscan(ipObj, cols, rows, direction, options);
// now build a raster display line by line
for (let r=0; r<rows; r++)
{
ipObj.buffer = dataBuf[r];
raster.newLine();
}
return raster.offScreenCvs;
}
}
}())

View File

@ -1,10 +1,13 @@
<script>
// deps
import BufferQueueNode from "$lib/queue";
import saveAs from "file-saver";
import { untrack } from "svelte";
import io from "socket.io-client";
import ADIF from "tcadif/lib/ADIF";
import { fade } from "svelte/transition";
import BufferQueueNode from "$lib/queue";
import { OpusDecoder } from "opus-decoder";
import { Waterfall } from "$lib/spectrogram";
function clamp(val, min, max) {
return Math.min(Math.max(val, min), max);
@ -14,6 +17,12 @@
return arr.reduce((prev, cur) => prev + cur, 0) / arr.length;
}
const modes = {
voice: "Voice",
ft8: "FT8",
psk31: "PSK31",
};
// state vars
let status = $state("disconnected");
let remoteState = $state();
@ -22,8 +31,13 @@
let dbm = $state(0);
let dbmList = $state([0]);
let pwr = $state(0);
let swr = $state(0);
let swrList = $state([0]);
let swr = $derived(avg(swrList));
let context = $state();
let micGain = $state(1);
let outputGain = $state(1);
let micLevel = $state(0);
let clipping = $state(false);
let recorder;
let sunits = $derived.by(() => {
let average = avg(dbmList);
@ -34,6 +48,33 @@
}
});
let socket;
let canvas;
let bands = $state([]);
let currentBand = $derived.by(() => {
try {
let bandKeys = Object.keys(bands);
let bandValues = Object.values(bands);
return bandKeys[
bandValues.indexOf(
bandValues.find(
(band) =>
band.edges[0] <= remoteState.frequency &&
band.edges[1] > remoteState.frequency
)
)
];
} catch {
return "";
}
});
let clubName = $state("");
let clubEmail = $state("");
let logbook = $state([]);
let outputGainNode;
let logCallsign = $state("");
let logTime = $state("");
let logRstTx = $state("");
let logRstRx = $state("");
async function enableMic() {
recorder = new Recorder({
@ -49,9 +90,24 @@
context = new AudioContext({ sampleRate: 48000 });
}
// when output gain is changed
$effect(() => {
let gainNode = untrack(() => outputGainNode);
let gain = outputGain;
if (gainNode) gainNode.gain.value = gain;
});
// when mic gain is changed
$effect(() => {
let leRecorder = untrack(() => recorder);
let gain = micGain;
if (leRecorder) leRecorder.setRecordingGain(gain);
});
// when the file is uploaded we do the thing
$effect(async () => {
if (files && files.length > 0) {
console.log("connecting :3");
status = "connecting";
let rawKey = await files[0].text();
try {
@ -65,11 +121,20 @@
let queueNode = new BufferQueueNode({
audioContext: context,
});
queueNode.connect(context.destination);
outputGainNode = new GainNode(context);
queueNode.connect(outputGainNode);
outputGainNode.connect(context.destination);
// temporary workaround to keep latency from getting too bad, need more empirical testing though
setInterval(() => {
queueNode.disconnect(outputGainNode);
queueNode = new BufferQueueNode({
audioContext: context,
});
queueNode.connect(outputGainNode);
}, 120 * 1000);
socket = io(json.url);
window.socket = socket;
socket.on("connect", () => {
status = "connected";
socket.emit("auth", rawKey);
});
socket.on("disconnect", () => {
@ -79,7 +144,7 @@
remoteState = state;
});
socket.on("dbm", (d) => {
dbm = Math.min(d, -33);
dbm = clamp(d, -121, -33);
dbmList.push(dbm);
if (dbmList.length > 25) dbmList.shift();
});
@ -87,7 +152,8 @@
pwr = p;
});
socket.on("swr", (s) => {
swr = s;
swrList.push(swr);
if (swrList.length > 10) swrList.shift();
});
socket.on("audio", (chunk) => {
queueNode._write(
@ -97,6 +163,37 @@
() => {}
);
});
socket.on("login", (info) => {
bands = info.bands;
clubName = info.clubName;
clubEmail = info.clubEmail;
socket.emit("getLogbook");
status = "connected";
});
socket.on("logbook", (logs) => {
logbook = logs;
});
socket.on("error", (err) => errors.unshift(err));
// i'm actually not sure we need to do this but meh
let untrackedRecorder = untrack(() => recorder);
let scriptNode =
untrackedRecorder.audioContext.createScriptProcessor(
4096,
1,
0
);
scriptNode.addEventListener("audioprocess", (e) => {
let data = e.inputBuffer.getChannelData(0);
clipping = false;
for (let i = 0; i < data.length; i++) {
if (Math.abs(data[i]) >= 1) {
clipping = true;
break;
}
}
micLevel = Math.min(avg(data.map((x) => Math.abs(x))), 1);
});
untrackedRecorder.recordingGainNode.connect(scriptNode);
} catch (e) {
console.log(e);
errors.unshift("Invalid key!");
@ -105,6 +202,32 @@
}
}
});
// submit entry function
function submitEntry() {
if (
logCallsign.length === 0 ||
logRstRx.length === 0 ||
logRstTx.length === 0 ||
logTime.length === 0 ||
!/^[0-9]{1,2}:[0-9]{2}$/.test(logTime)
)
return;
let date = new Date();
socket.emit("newEntry", {
TIME_ON: logTime.replace(":", "").padStart(4, "0").padEnd(6, "0"),
CALL: logCallsign.toUpperCase(),
MODE: "SSB",
QSO_DATE: `${date.getUTCFullYear()}${date.getUTCMonth().toString().padStart(2, "0")}${date.getUTCDate().toString().padStart(2, "0")}`,
RST_RCVD: logRstRx,
RST_SENT: logRstTx,
FREQ: (remoteState.frequency / 100000).toString(),
});
logTime = "";
logCallsign = "";
logRstRx = "";
logRstTx = "";
}
</script>
{#if status === "disconnected"}
@ -113,7 +236,7 @@
>
<h1 class="text-3xl font-semibold">freeremote</h1>
{#if context}
<p>Please upload your key below to access your remote station.</p>
<p class="text-center">Please upload your key below to access your remote station.</p>
<input
accept="text/plain"
bind:files
@ -134,9 +257,419 @@
<span class="loading loading-spinner loading-xl"></span>
</div>
{:else}
<p>{remoteState?.frequency}</p>
<p>{dbm}</p>
<p>{sunits}</p>
<div class="w-screen min-h-screen flex flex-col">
<div
class="w-screen bg-base-200 shadow-sm flex items-center px-2 py-2 gap-2"
>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-soft">
{currentBand}
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-200 rounded-box z-1 w-52 p-2 shadow-sm"
>
{#each Object.keys(bands) as band}
<li>
<a
onclick={() => {
if (remoteState.mode === "voice") {
socket.emit(
"frequency",
bands[band].voice[0]
);
} else if (remoteState.mode === "psk31") {
socket.emit(
"frequency",
bands[band].psk31
);
} else {
socket.emit(
"frequency",
bands[band].ft8
);
}
document.activeElement.blur();
}}>{band}</a
>
</li>
{/each}
</ul>
</div>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-soft">
{modes[remoteState?.mode]}
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-200 rounded-box z-1 w-52 p-2 shadow-sm"
>
<li><a>Voice</a></li>
<li><a>FT8</a></li>
<li><a>PSK31</a></li>
</ul>
</div>
<button class="btn btn-soft btn-success" onclick={() => socket.emit("tune")}>Tune</button>
<div class="flex-grow"></div>
<p>
<a class="link" href="mailto:{clubEmail}">{clubName}</a> - {remoteState
?.currentUser.callsign}
</p>
</div>
{#if remoteState?.mode === "voice"}
<div class="flex flex-col flex-grow items-center p-4">
<div class="flex lg:flex-row md:flex-row flex-col w-full flex-grow gap-4">
<div class="flex flex-col items-center justify-center gap-4">
<div
class="card card-border bg-base-200 lg:w-[30vw] md:w-[50vw] w-[85vw]"
>
<div class="card-body font-mono">
<div class="lg:hidden md:hidden flex flex-row">
<button class="btn btn-soft" onclick={() => socket.emit("frequency", remoteState.frequency - 100)}>
-1 kHz
</button>
<div class="flex-grow">
</div>
<button class="btn btn-soft" onclick={() => socket.emit("frequency", remoteState.frequency + 100)}>
+1 kHz
</button>
</div>
<div
class="flex flex-row items-center justify-center"
>
{#each [7, 6, 5, 4, 3, 2, 1] as i}
<h1
class="text-6xl"
onwheel={(e) => {
e.preventDefault();
if (e.deltaY < 0) {
socket.emit(
"frequency",
remoteState.frequency +
10 ** (i - 1)
);
} else {
socket.emit(
"frequency",
remoteState.frequency -
10 ** (i - 1)
);
}
}}
>
{(() => {
let digit = Math.floor(
(remoteState?.frequency %
10 ** i) /
10 ** (i - 1)
);
if (isNaN(digit)) return 0;
else return digit;
})()}
</h1>
{#if i === 3}
<h1 class="text-6xl">.</h1>
{/if}
{/each}
</div>
</div>
</div>
<div
class="rounded-box border border-base-content/5 bg-base-200 lg:w-[30vw] md:w-[50vw] w-[85vw]"
>
<table class="table">
<tbody>
<tr>
<td>
<progress
class="progress progress-primary"
value={dbm + 121}
max="88"
></progress>
</td>
<td>
<div
class="tooltip"
data-tip={`${dbm} dBm`}
>
<p>{sunits}</p>
</div>
</td>
</tr>
<tr>
<td>
<progress
class="progress progress-warning"
value={pwr}
max={remoteState?.maxpwr}
></progress>
</td>
<td>
<div
class="tooltip"
data-tip="May not work on some transceivers."
>
<p>{pwr}W</p>
</div>
</td>
</tr>
<tr>
<td>
<progress
class="progress progress-error"
value={swr}
max="100"
></progress>
</td>
<td>
<div
class="tooltip"
data-tip="SWR is approximate. Accuracy not guaranteed."
>
<p>
{(() => {
if (swr > 50)
return "∞";
else
return (
1 +
Math.floor(
(swr / 11) *
0.5 *
10
) /
10
);
})()}:1
</p>
</div>
</td>
</tr>
<tr>
<td>
<progress
class="progress {clipping
? 'progress-error'
: 'progress-success'}"
value={micLevel}
max="1"
></progress>
</td>
<td>
<div
class="tooltip"
data-tip="Set the mic gain so that this meter is as high as possible when you speak. If the meter is red while you speak, your voice may be clipping."
>
<p>Mic</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div
class="card card-border bg-base-200 lg:w-[30vw] md:w-[50vw] w-[85vw]"
>
<div class="card-body w-full">
<p class="text-center">Output gain</p>
<div class="w-full">
<input
type="range"
min="0"
max="3"
bind:value={outputGain}
class="w-full range"
step="0.1"
/>
<div
class="flex justify-between px-2.5 mt-2 text-xs"
>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
</div>
<div
class="flex justify-between px-2.5 mt-2 text-xs"
>
<span>Muted</span>
<span>100%</span>
<span>200%</span>
<span>300%</span>
</div>
</div>
<p class="text-center">Mic gain</p>
<div class="w-full">
<input
type="range"
min="0"
max="3"
bind:value={micGain}
class="w-full range"
step="0.1"
/>
<div
class="flex justify-between px-2.5 mt-2 text-xs"
>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
</div>
<div
class="flex justify-between px-2.5 mt-2 text-xs"
>
<span>Muted</span>
<span>100%</span>
<span>200%</span>
<span>300%</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-col flex-grow gap-4">
<div class="flex flex-row gap-2">
<h1 class="text-3xl font-semibold">Logbook</h1>
<button
class="btn btn-square btn-soft material-symbols-outlined"
onclick={() => {
let blob = new Blob(
[
new ADIF({
qsos: logbook,
}).stringify({
fieldDelim: "\n",
recordDelim: "\n",
}),
],
{ type: "text/plain;charset=utf-8;" }
);
saveAs(
blob,
`${remoteState.currentUser.callsign}.adi`
);
}}
>
download
</button>
</div>
<div class="flex flex-col gap-2">
<div class="flex lg:flex-row flex-col gap-2">
<input
type="text"
placeholder="Callsign"
class="input"
bind:value={logCallsign}
/>
<div class="join">
<input
type="text"
placeholder="Time (UTC)"
class="input join-item"
bind:value={logTime}
/>
<button
class="btn btn-soft join-item"
onclick={() => {
let date = new Date();
logTime = `${date.getUTCHours().toString().padStart(2, "0")}:${date.getUTCMinutes().toString().padStart(2, "0")}`;
}}
>
Now
</button>
</div>
<input
type="text"
placeholder="RST (sent)"
class="input"
bind:value={logRstTx}
/>
<input
type="text"
placeholder="RST (rcvd)"
class="input"
bind:value={logRstRx}
/>
</div>
<div>
<button
class="btn btn-primary"
onclick={submitEntry}
>
Add entry
</button>
</div>
</div>
{#if logbook.length === 0}
<div
class="flex flex-col flex-grow w-full items-center justify-center rounded-box border border-base-content/5 bg-base-200 max-h-[60vh]"
>
<p>No entries found.</p>
</div>
{:else}
<div
class="overflow-y-scroll overflow-x-auto rounded-box border border-base-content/5 bg-base-200 w-full max-h-[60vh] flex-grow"
>
<table class="table">
<thead>
<tr>
<th>Callsign</th>
<th>Mode</th>
<th>Freq (MHz)</th>
<th>RST (sent)</th>
<th>RST (rcvd)</th>
<th>Date (UTC)</th>
<th>Time (UTC)</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
{#each logbook as entry, i}
<tr>
<td>{entry.CALL}</td>
<td>{entry.MODE}</td>
<td>{entry.FREQ}</td>
<td>{entry.RST_SENT}</td>
<td>{entry.RST_RCVD}</td>
<td>{entry.QSO_DATE}</td>
<td>{entry.TIME_ON}</td>
<td>
<button
class="btn btn-soft btn-square btn-error material-symbols-outlined"
onclick={() => {
logbook.splice(i, 1);
socket.emit("updateLogbook", logbook);
}}
>
delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</div>
<div class="toast toast-bottom toast-end">
<button
class="btn btn-xl {remoteState?.transmitting
? 'btn-error'
: 'btn-primary'} btn-circle"
onclick={() => {
if (!remoteState.transmitting) socket.emit("ptt");
else socket.emit("unptt");
}}
>
<div class="material-symbols-outlined">mic</div>
</button>
</div>
{/if}
</div>
<!--<canvas bind:this={canvas} class="w-full h-[25vh]"></canvas>-->
{/if}
<div class="toast toast-top toast-end">

View File

@ -3,10 +3,10 @@
var OggOpusEncoder, OpusEncoderLib;
if(typeof require === 'function'){
OpusEncoderLib = require('./libopus-encoder.js');
OpusEncoderLib = require('./libopus-encoder.wasm.min.js');
OggOpusEncoder = require('./oggOpusEncoder.js').OggOpusEncoder;
} else if (typeof importScripts === "function") {
importScripts('./libopus-encoder.js');
importScripts('./libopus-encoder.wasm.min.js');
importScripts('./oggOpusEncoder.js');
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,5 +1,12 @@
import adapter from '@sveltejs/adapter-static';
import adapter from "@sveltejs/adapter-static";
const config = { kit: { adapter: adapter() } };
const config = {
kit: { adapter: adapter() },
compilerOptions: {
warningFilter: (warning) =>
!warning.filename?.includes("node_modules") &&
!warning.code.startsWith("a11y"),
},
};
export default config;