[feat](trx-frontend-http): extract APRS to separate file and add CW decoder plugin
Extract the APRS decoder from app.js into its own aprs.js file and add a new CW (Morse code) decoder plugin in cw.js. The CW decoder uses a Goertzel tone detector with configurable WPM, tone frequency, and signal threshold. The Plugins tab now has three sub-tabs: Overview, APRS, and CW. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -650,458 +650,6 @@ document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- APRS Decoder Plugin ---
|
|
||||||
const aprsToggleBtn = document.getElementById("aprs-toggle-btn");
|
|
||||||
const aprsStatus = document.getElementById("aprs-status");
|
|
||||||
const aprsPacketsEl = document.getElementById("aprs-packets");
|
|
||||||
const APRS_MAX_PACKETS = 100;
|
|
||||||
|
|
||||||
let aprsActive = false;
|
|
||||||
let aprsWs = null;
|
|
||||||
let aprsAudioCtx = null;
|
|
||||||
let aprsDecoder = null;
|
|
||||||
|
|
||||||
// CRC-16-CCITT lookup table
|
|
||||||
const CRC_CCITT_TABLE = new Uint16Array(256);
|
|
||||||
(function initCrc() {
|
|
||||||
for (let i = 0; i < 256; i++) {
|
|
||||||
let crc = i;
|
|
||||||
for (let j = 0; j < 8; j++) {
|
|
||||||
crc = (crc & 1) ? ((crc >>> 1) ^ 0x8408) : (crc >>> 1);
|
|
||||||
}
|
|
||||||
CRC_CCITT_TABLE[i] = crc;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
function crc16ccitt(bytes) {
|
|
||||||
let crc = 0xFFFF;
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
crc = (crc >>> 8) ^ CRC_CCITT_TABLE[(crc ^ bytes[i]) & 0xFF];
|
|
||||||
}
|
|
||||||
return crc ^ 0xFFFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFSK Bell 202 Demodulator (1200 baud, mark=1200Hz, space=2200Hz)
|
|
||||||
// Uses delay-and-multiply frequency discriminator for robust non-coherent decoding.
|
|
||||||
function createDemodulator(sampleRate) {
|
|
||||||
const BAUD = 1200;
|
|
||||||
const MARK = 1200;
|
|
||||||
const SPACE = 2200;
|
|
||||||
const samplesPerBit = sampleRate / BAUD;
|
|
||||||
|
|
||||||
// Debug counters
|
|
||||||
let dbgSamples = 0;
|
|
||||||
let dbgBits = 0;
|
|
||||||
let dbgFlags = 0;
|
|
||||||
let dbgFrameAttempts = 0;
|
|
||||||
let dbgCrcFails = 0;
|
|
||||||
let dbgFramesOk = 0;
|
|
||||||
let dbgLastLog = 0;
|
|
||||||
|
|
||||||
// Energy gate — reset demodulator when signal is absent
|
|
||||||
let energyAcc = 0;
|
|
||||||
let energyCount = 0;
|
|
||||||
const ENERGY_WINDOW = Math.round(sampleRate * 0.05);
|
|
||||||
const ENERGY_THRESHOLD = 0.001;
|
|
||||||
|
|
||||||
// Bandpass pre-filter: biquad centered at 1700 Hz (AFSK midpoint), Q=1.2
|
|
||||||
// Passes ~1000-2500 Hz, removes out-of-band noise
|
|
||||||
const bpfW0 = 2 * Math.PI * ((MARK + SPACE) / 2) / sampleRate;
|
|
||||||
const bpfAlpha = Math.sin(bpfW0) / (2 * 1.2);
|
|
||||||
const bpfA0 = 1 + bpfAlpha;
|
|
||||||
const bpfCoeffs = {
|
|
||||||
b0: bpfAlpha / bpfA0, b1: 0, b2: -bpfAlpha / bpfA0,
|
|
||||||
a1: (-2 * Math.cos(bpfW0)) / bpfA0, a2: (1 - bpfAlpha) / bpfA0,
|
|
||||||
};
|
|
||||||
let bpfState = [0, 0, 0, 0]; // x1, x2, y1, y2
|
|
||||||
|
|
||||||
// Delay-and-multiply discriminator
|
|
||||||
// Delay = Fs / (2 * Δf) where Δf = space - mark = 1000 Hz
|
|
||||||
// At this delay: mark tone → negative product, space tone → positive product
|
|
||||||
const DELAY = Math.round(sampleRate / (2 * (SPACE - MARK)));
|
|
||||||
const delayBuf = new Float32Array(DELAY);
|
|
||||||
let delayIdx = 0;
|
|
||||||
|
|
||||||
// Moving-average LPF over half a bit period
|
|
||||||
// First null at 2*mark freq (2400 Hz), cleanly removing discriminator artifacts
|
|
||||||
const avgLen = Math.round(samplesPerBit / 2);
|
|
||||||
const avgBuf = new Float32Array(avgLen);
|
|
||||||
let avgIdx = 0;
|
|
||||||
let avgSum = 0;
|
|
||||||
|
|
||||||
// Clock recovery (PLL)
|
|
||||||
let lastTone = 0;
|
|
||||||
let bitPhase = 0;
|
|
||||||
const PLL_GAIN = 0.7;
|
|
||||||
|
|
||||||
// NRZI state
|
|
||||||
let prevSampledBit = 0;
|
|
||||||
|
|
||||||
// HDLC state
|
|
||||||
let ones = 0;
|
|
||||||
let frameBits = [];
|
|
||||||
let inFrame = false;
|
|
||||||
|
|
||||||
const frames = [];
|
|
||||||
|
|
||||||
function resetState() {
|
|
||||||
bpfState = [0, 0, 0, 0];
|
|
||||||
delayBuf.fill(0);
|
|
||||||
delayIdx = 0;
|
|
||||||
avgBuf.fill(0);
|
|
||||||
avgIdx = 0;
|
|
||||||
avgSum = 0;
|
|
||||||
lastTone = 0;
|
|
||||||
bitPhase = 0;
|
|
||||||
prevSampledBit = 0;
|
|
||||||
ones = 0;
|
|
||||||
frameBits = [];
|
|
||||||
inFrame = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function processSample(s) {
|
|
||||||
// Energy gate
|
|
||||||
energyAcc += s * s;
|
|
||||||
energyCount++;
|
|
||||||
if (energyCount >= ENERGY_WINDOW) {
|
|
||||||
if (Math.sqrt(energyAcc / energyCount) < ENERGY_THRESHOLD) {
|
|
||||||
resetState();
|
|
||||||
}
|
|
||||||
energyAcc = 0;
|
|
||||||
energyCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bandpass pre-filter
|
|
||||||
const c = bpfCoeffs;
|
|
||||||
const filtered = c.b0 * s + c.b1 * bpfState[0] + c.b2 * bpfState[1]
|
|
||||||
- c.a1 * bpfState[2] - c.a2 * bpfState[3];
|
|
||||||
bpfState[1] = bpfState[0]; bpfState[0] = s;
|
|
||||||
bpfState[3] = bpfState[2]; bpfState[2] = filtered;
|
|
||||||
|
|
||||||
// Delay-and-multiply frequency discriminator
|
|
||||||
const delayed = delayBuf[delayIdx];
|
|
||||||
delayBuf[delayIdx] = filtered;
|
|
||||||
delayIdx = (delayIdx + 1) % DELAY;
|
|
||||||
|
|
||||||
const disc = filtered * delayed;
|
|
||||||
|
|
||||||
// Moving-average LPF
|
|
||||||
avgSum += disc - avgBuf[avgIdx];
|
|
||||||
avgBuf[avgIdx] = disc;
|
|
||||||
avgIdx = (avgIdx + 1) % avgLen;
|
|
||||||
|
|
||||||
// mark (1200 Hz) → negative, space (2200 Hz) → positive
|
|
||||||
const bit = avgSum < 0 ? 1 : 0;
|
|
||||||
|
|
||||||
// PLL clock recovery
|
|
||||||
if (bit !== lastTone) {
|
|
||||||
lastTone = bit;
|
|
||||||
const error = bitPhase - samplesPerBit / 2;
|
|
||||||
bitPhase -= PLL_GAIN * error;
|
|
||||||
}
|
|
||||||
|
|
||||||
bitPhase--;
|
|
||||||
if (bitPhase <= 0) {
|
|
||||||
bitPhase += samplesPerBit;
|
|
||||||
dbgBits++;
|
|
||||||
processBit(bit);
|
|
||||||
}
|
|
||||||
|
|
||||||
dbgSamples++;
|
|
||||||
}
|
|
||||||
|
|
||||||
function processBit(rawBit) {
|
|
||||||
// NRZI decode: no transition = 1, transition = 0
|
|
||||||
const decodedBit = (rawBit === prevSampledBit) ? 1 : 0;
|
|
||||||
prevSampledBit = rawBit;
|
|
||||||
|
|
||||||
if (decodedBit === 1) {
|
|
||||||
// Don't push yet — buffer in ones counter until we know
|
|
||||||
// these aren't part of a flag, stuff, or abort sequence
|
|
||||||
ones++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodedBit === 0
|
|
||||||
if (ones >= 7) {
|
|
||||||
// Abort sequence — reset
|
|
||||||
inFrame = false;
|
|
||||||
frameBits = [];
|
|
||||||
ones = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ones === 6) {
|
|
||||||
// Flag (01111110) — frame boundary; the 6 ones are flag bits, not data
|
|
||||||
dbgFlags++;
|
|
||||||
if (inFrame && frameBits.length >= 136) {
|
|
||||||
dbgFrameAttempts++;
|
|
||||||
const result = bitsToBytes(frameBits);
|
|
||||||
if (result) {
|
|
||||||
if (result.crcOk) dbgFramesOk++;
|
|
||||||
frames.push(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
frameBits = [];
|
|
||||||
inFrame = true;
|
|
||||||
ones = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ones === 5) {
|
|
||||||
// Bit stuffing — flush the 5 data ones, discard the stuffed zero
|
|
||||||
if (inFrame) {
|
|
||||||
for (let k = 0; k < 5; k++) frameBits.push(1);
|
|
||||||
}
|
|
||||||
ones = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal data: flush buffered ones then push the zero
|
|
||||||
if (inFrame) {
|
|
||||||
for (let k = 0; k < ones; k++) frameBits.push(1);
|
|
||||||
frameBits.push(0);
|
|
||||||
}
|
|
||||||
ones = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bitsToBytes(bits) {
|
|
||||||
const byteLen = Math.floor(bits.length / 8);
|
|
||||||
if (byteLen < 17) return null;
|
|
||||||
const bytes = new Uint8Array(byteLen);
|
|
||||||
for (let i = 0; i < byteLen; i++) {
|
|
||||||
let b = 0;
|
|
||||||
for (let j = 0; j < 8; j++) {
|
|
||||||
b |= (bits[i * 8 + j] << j);
|
|
||||||
}
|
|
||||||
bytes[i] = b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify FCS (last 2 bytes)
|
|
||||||
const payload = bytes.subarray(0, byteLen - 2);
|
|
||||||
const fcs = bytes[byteLen - 2] | (bytes[byteLen - 1] << 8);
|
|
||||||
const computed = crc16ccitt(payload);
|
|
||||||
if (computed !== fcs) {
|
|
||||||
dbgCrcFails++;
|
|
||||||
// Try to decode addresses for diagnostics
|
|
||||||
let addrInfo = "";
|
|
||||||
if (payload.length >= 14) {
|
|
||||||
const dstCall = Array.from(payload.subarray(0, 6)).map(b => String.fromCharCode(b >> 1)).join("").trim();
|
|
||||||
const srcCall = Array.from(payload.subarray(7, 13)).map(b => String.fromCharCode(b >> 1)).join("").trim();
|
|
||||||
addrInfo = ` dst="${dstCall}" src="${srcCall}"`;
|
|
||||||
}
|
|
||||||
console.debug("[APRS-DBG] CRC fail:", byteLen, "bytes, fcs=0x" + fcs.toString(16),
|
|
||||||
"computed=0x" + computed.toString(16), "bits:", bits.length, addrInfo,
|
|
||||||
"hex:", Array.from(bytes.subarray(0, Math.min(20, byteLen))).map(b => b.toString(16).padStart(2, "0")).join(" "));
|
|
||||||
// Return as suspect frame for display
|
|
||||||
return { payload, crcOk: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { payload, crcOk: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function processBuffer(samples) {
|
|
||||||
for (let i = 0; i < samples.length; i++) {
|
|
||||||
processSample(samples[i]);
|
|
||||||
}
|
|
||||||
// Periodic debug log every 3 seconds
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - dbgLastLog >= 3000) {
|
|
||||||
console.log("[APRS-DBG] samples:", dbgSamples, "bits:", dbgBits, "flags:", dbgFlags,
|
|
||||||
"frameAttempts:", dbgFrameAttempts, "crcFails:", dbgCrcFails, "ok:", dbgFramesOk);
|
|
||||||
dbgLastLog = now;
|
|
||||||
}
|
|
||||||
const result = frames.splice(0);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { processBuffer };
|
|
||||||
}
|
|
||||||
|
|
||||||
// AX.25 address extraction
|
|
||||||
function decodeAX25Address(bytes, offset) {
|
|
||||||
let call = "";
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
const ch = bytes[offset + i] >> 1;
|
|
||||||
if (ch > 32) call += String.fromCharCode(ch);
|
|
||||||
}
|
|
||||||
call = call.trimEnd();
|
|
||||||
const ssid = (bytes[offset + 6] >> 1) & 0x0F;
|
|
||||||
const last = (bytes[offset + 6] & 0x01) === 1;
|
|
||||||
return { call, ssid, last };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAX25(frame) {
|
|
||||||
if (frame.length < 16) return null;
|
|
||||||
const dest = decodeAX25Address(frame, 0);
|
|
||||||
const src = decodeAX25Address(frame, 7);
|
|
||||||
|
|
||||||
let offset = 14;
|
|
||||||
const digis = [];
|
|
||||||
let lastAddr = src.last;
|
|
||||||
while (!lastAddr && offset + 7 <= frame.length) {
|
|
||||||
const digi = decodeAX25Address(frame, offset);
|
|
||||||
digis.push(digi);
|
|
||||||
lastAddr = digi.last;
|
|
||||||
offset += 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offset + 2 > frame.length) return null;
|
|
||||||
const control = frame[offset];
|
|
||||||
const pid = frame[offset + 1];
|
|
||||||
const info = frame.subarray(offset + 2);
|
|
||||||
|
|
||||||
return { src, dest, digis, control, pid, info };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAPRS(ax25) {
|
|
||||||
const srcCall = ax25.src.ssid ? `${ax25.src.call}-${ax25.src.ssid}` : ax25.src.call;
|
|
||||||
const destCall = ax25.dest.ssid ? `${ax25.dest.call}-${ax25.dest.ssid}` : ax25.dest.call;
|
|
||||||
const path = ax25.digis.map((d) => d.ssid ? `${d.call}-${d.ssid}` : d.call).join(",");
|
|
||||||
const infoStr = new TextDecoder().decode(ax25.info);
|
|
||||||
|
|
||||||
let type = "Unknown";
|
|
||||||
if (infoStr.length > 0) {
|
|
||||||
const dt = infoStr[0];
|
|
||||||
if (dt === "!" || dt === "=" || dt === "/" || dt === "@") type = "Position";
|
|
||||||
else if (dt === ":") type = "Message";
|
|
||||||
else if (dt === ">") type = "Status";
|
|
||||||
else if (dt === "T") type = "Telemetry";
|
|
||||||
else if (dt === ";") type = "Object";
|
|
||||||
else if (dt === ")") type = "Item";
|
|
||||||
else if (dt === "`" || dt === "'") type = "Mic-E";
|
|
||||||
}
|
|
||||||
|
|
||||||
return { srcCall, destCall, path, info: infoStr, type };
|
|
||||||
}
|
|
||||||
|
|
||||||
function addAprsPacket(pkt) {
|
|
||||||
const tag = pkt.crcOk ? "[APRS]" : "[APRS-CRC-FAIL]";
|
|
||||||
console.log(tag, `${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}`, pkt);
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "aprs-packet";
|
|
||||||
if (!pkt.crcOk) row.style.opacity = "0.5";
|
|
||||||
const now = new Date();
|
|
||||||
const ts = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
const crcTag = pkt.crcOk ? "" : ' <span style="color:var(--accent-red);">[CRC]</span>';
|
|
||||||
row.innerHTML = `<span class="aprs-time">${ts}</span><span class="aprs-call">${pkt.srcCall}</span>>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: <span title="${pkt.type}">${pkt.info}</span>${crcTag}`;
|
|
||||||
aprsPacketsEl.prepend(row);
|
|
||||||
while (aprsPacketsEl.children.length > APRS_MAX_PACKETS) {
|
|
||||||
aprsPacketsEl.removeChild(aprsPacketsEl.lastChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAprs() {
|
|
||||||
if (aprsActive) { stopAprs(); return; }
|
|
||||||
if (!hasWebCodecs) {
|
|
||||||
aprsStatus.textContent = "Requires Chrome/Edge";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
aprsWs = new WebSocket(`${proto}//${location.host}/audio`);
|
|
||||||
aprsWs.binaryType = "arraybuffer";
|
|
||||||
aprsStatus.textContent = "Connecting…";
|
|
||||||
|
|
||||||
let demodulator = null;
|
|
||||||
|
|
||||||
aprsWs.onopen = () => {
|
|
||||||
aprsStatus.textContent = "Waiting for stream info…";
|
|
||||||
};
|
|
||||||
|
|
||||||
aprsWs.onmessage = (evt) => {
|
|
||||||
if (typeof evt.data === "string") {
|
|
||||||
try {
|
|
||||||
const info = JSON.parse(evt.data);
|
|
||||||
const sr = info.sample_rate || 48000;
|
|
||||||
const ch = info.channels || 1;
|
|
||||||
aprsAudioCtx = new AudioContext({ sampleRate: sr });
|
|
||||||
demodulator = createDemodulator(sr);
|
|
||||||
|
|
||||||
let aprsFrameCount = 0;
|
|
||||||
aprsDecoder = new AudioDecoder({
|
|
||||||
output: (frame) => {
|
|
||||||
if (aprsFrameCount++ === 0) {
|
|
||||||
console.log("[APRS-DBG] First PCM frame:", frame.numberOfFrames, "samples,", frame.numberOfChannels, "ch, format:", frame.format, "sr:", frame.sampleRate);
|
|
||||||
}
|
|
||||||
const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels);
|
|
||||||
frame.copyTo(buf, { planeIndex: 0 });
|
|
||||||
// Use first channel only
|
|
||||||
let mono;
|
|
||||||
if (frame.numberOfChannels === 1) {
|
|
||||||
mono = buf;
|
|
||||||
} else {
|
|
||||||
mono = new Float32Array(frame.numberOfFrames);
|
|
||||||
for (let i = 0; i < frame.numberOfFrames; i++) {
|
|
||||||
mono[i] = buf[i * frame.numberOfChannels];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const frames = demodulator.processBuffer(mono);
|
|
||||||
for (const result of frames) {
|
|
||||||
const ax25 = parseAX25(result.payload);
|
|
||||||
if (!ax25) continue;
|
|
||||||
const pkt = parseAPRS(ax25);
|
|
||||||
pkt.crcOk = result.crcOk;
|
|
||||||
addAprsPacket(pkt);
|
|
||||||
}
|
|
||||||
frame.close();
|
|
||||||
},
|
|
||||||
error: (e) => { console.error("APRS AudioDecoder error", e); }
|
|
||||||
});
|
|
||||||
aprsDecoder.configure({
|
|
||||||
codec: "opus",
|
|
||||||
sampleRate: sr,
|
|
||||||
numberOfChannels: ch,
|
|
||||||
});
|
|
||||||
|
|
||||||
aprsActive = true;
|
|
||||||
aprsToggleBtn.style.borderColor = "#00d17f";
|
|
||||||
aprsToggleBtn.style.color = "#00d17f";
|
|
||||||
aprsToggleBtn.textContent = "Stop APRS";
|
|
||||||
aprsStatus.textContent = "Listening…";
|
|
||||||
} catch (e) {
|
|
||||||
console.error("APRS stream info error", e);
|
|
||||||
aprsStatus.textContent = "Error";
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binary Opus data
|
|
||||||
if (!aprsDecoder) return;
|
|
||||||
try {
|
|
||||||
aprsDecoder.decode(new EncodedAudioChunk({
|
|
||||||
type: "key",
|
|
||||||
timestamp: performance.now() * 1000,
|
|
||||||
data: new Uint8Array(evt.data),
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore individual decode errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
aprsWs.onclose = () => {
|
|
||||||
stopAprs();
|
|
||||||
};
|
|
||||||
|
|
||||||
aprsWs.onerror = () => {
|
|
||||||
aprsStatus.textContent = "Connection error";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAprs() {
|
|
||||||
aprsActive = false;
|
|
||||||
if (aprsWs) { aprsWs.close(); aprsWs = null; }
|
|
||||||
if (aprsAudioCtx) { aprsAudioCtx.close(); aprsAudioCtx = null; }
|
|
||||||
if (aprsDecoder) {
|
|
||||||
try { aprsDecoder.close(); } catch (e) {}
|
|
||||||
aprsDecoder = null;
|
|
||||||
}
|
|
||||||
aprsToggleBtn.style.borderColor = "";
|
|
||||||
aprsToggleBtn.style.color = "";
|
|
||||||
aprsToggleBtn.textContent = "Start APRS";
|
|
||||||
aprsStatus.textContent = "Stopped";
|
|
||||||
}
|
|
||||||
|
|
||||||
aprsToggleBtn.addEventListener("click", startAprs);
|
|
||||||
|
|
||||||
// --- Signal measurement ---
|
// --- Signal measurement ---
|
||||||
const sigMeasureBtn = document.getElementById("sig-measure-btn");
|
const sigMeasureBtn = document.getElementById("sig-measure-btn");
|
||||||
const sigClearBtn = document.getElementById("sig-clear-btn");
|
const sigClearBtn = document.getElementById("sig-clear-btn");
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
// --- APRS Decoder Plugin ---
|
||||||
|
const aprsToggleBtn = document.getElementById("aprs-toggle-btn");
|
||||||
|
const aprsStatus = document.getElementById("aprs-status");
|
||||||
|
const aprsPacketsEl = document.getElementById("aprs-packets");
|
||||||
|
const APRS_MAX_PACKETS = 100;
|
||||||
|
|
||||||
|
let aprsActive = false;
|
||||||
|
let aprsWs = null;
|
||||||
|
let aprsAudioCtx = null;
|
||||||
|
let aprsDecoder = null;
|
||||||
|
|
||||||
|
// CRC-16-CCITT lookup table
|
||||||
|
const CRC_CCITT_TABLE = new Uint16Array(256);
|
||||||
|
(function initCrc() {
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let crc = i;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
crc = (crc & 1) ? ((crc >>> 1) ^ 0x8408) : (crc >>> 1);
|
||||||
|
}
|
||||||
|
CRC_CCITT_TABLE[i] = crc;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc16ccitt(bytes) {
|
||||||
|
let crc = 0xFFFF;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
crc = (crc >>> 8) ^ CRC_CCITT_TABLE[(crc ^ bytes[i]) & 0xFF];
|
||||||
|
}
|
||||||
|
return crc ^ 0xFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFSK Bell 202 Demodulator (1200 baud, mark=1200Hz, space=2200Hz)
|
||||||
|
// Uses delay-and-multiply frequency discriminator for robust non-coherent decoding.
|
||||||
|
function createDemodulator(sampleRate) {
|
||||||
|
const BAUD = 1200;
|
||||||
|
const MARK = 1200;
|
||||||
|
const SPACE = 2200;
|
||||||
|
const samplesPerBit = sampleRate / BAUD;
|
||||||
|
|
||||||
|
// Debug counters
|
||||||
|
let dbgSamples = 0;
|
||||||
|
let dbgBits = 0;
|
||||||
|
let dbgFlags = 0;
|
||||||
|
let dbgFrameAttempts = 0;
|
||||||
|
let dbgCrcFails = 0;
|
||||||
|
let dbgFramesOk = 0;
|
||||||
|
let dbgLastLog = 0;
|
||||||
|
|
||||||
|
// Energy gate — reset demodulator when signal is absent
|
||||||
|
let energyAcc = 0;
|
||||||
|
let energyCount = 0;
|
||||||
|
const ENERGY_WINDOW = Math.round(sampleRate * 0.05);
|
||||||
|
const ENERGY_THRESHOLD = 0.001;
|
||||||
|
|
||||||
|
// Bandpass pre-filter: biquad centered at 1700 Hz (AFSK midpoint), Q=1.2
|
||||||
|
// Passes ~1000-2500 Hz, removes out-of-band noise
|
||||||
|
const bpfW0 = 2 * Math.PI * ((MARK + SPACE) / 2) / sampleRate;
|
||||||
|
const bpfAlpha = Math.sin(bpfW0) / (2 * 1.2);
|
||||||
|
const bpfA0 = 1 + bpfAlpha;
|
||||||
|
const bpfCoeffs = {
|
||||||
|
b0: bpfAlpha / bpfA0, b1: 0, b2: -bpfAlpha / bpfA0,
|
||||||
|
a1: (-2 * Math.cos(bpfW0)) / bpfA0, a2: (1 - bpfAlpha) / bpfA0,
|
||||||
|
};
|
||||||
|
let bpfState = [0, 0, 0, 0]; // x1, x2, y1, y2
|
||||||
|
|
||||||
|
// Delay-and-multiply discriminator
|
||||||
|
// Delay = Fs / (2 * Δf) where Δf = space - mark = 1000 Hz
|
||||||
|
// At this delay: mark tone → negative product, space tone → positive product
|
||||||
|
const DELAY = Math.round(sampleRate / (2 * (SPACE - MARK)));
|
||||||
|
const delayBuf = new Float32Array(DELAY);
|
||||||
|
let delayIdx = 0;
|
||||||
|
|
||||||
|
// Moving-average LPF over half a bit period
|
||||||
|
// First null at 2*mark freq (2400 Hz), cleanly removing discriminator artifacts
|
||||||
|
const avgLen = Math.round(samplesPerBit / 2);
|
||||||
|
const avgBuf = new Float32Array(avgLen);
|
||||||
|
let avgIdx = 0;
|
||||||
|
let avgSum = 0;
|
||||||
|
|
||||||
|
// Clock recovery (PLL)
|
||||||
|
let lastTone = 0;
|
||||||
|
let bitPhase = 0;
|
||||||
|
const PLL_GAIN = 0.7;
|
||||||
|
|
||||||
|
// NRZI state
|
||||||
|
let prevSampledBit = 0;
|
||||||
|
|
||||||
|
// HDLC state
|
||||||
|
let ones = 0;
|
||||||
|
let frameBits = [];
|
||||||
|
let inFrame = false;
|
||||||
|
|
||||||
|
const frames = [];
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
bpfState = [0, 0, 0, 0];
|
||||||
|
delayBuf.fill(0);
|
||||||
|
delayIdx = 0;
|
||||||
|
avgBuf.fill(0);
|
||||||
|
avgIdx = 0;
|
||||||
|
avgSum = 0;
|
||||||
|
lastTone = 0;
|
||||||
|
bitPhase = 0;
|
||||||
|
prevSampledBit = 0;
|
||||||
|
ones = 0;
|
||||||
|
frameBits = [];
|
||||||
|
inFrame = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processSample(s) {
|
||||||
|
// Energy gate
|
||||||
|
energyAcc += s * s;
|
||||||
|
energyCount++;
|
||||||
|
if (energyCount >= ENERGY_WINDOW) {
|
||||||
|
if (Math.sqrt(energyAcc / energyCount) < ENERGY_THRESHOLD) {
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
energyAcc = 0;
|
||||||
|
energyCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bandpass pre-filter
|
||||||
|
const c = bpfCoeffs;
|
||||||
|
const filtered = c.b0 * s + c.b1 * bpfState[0] + c.b2 * bpfState[1]
|
||||||
|
- c.a1 * bpfState[2] - c.a2 * bpfState[3];
|
||||||
|
bpfState[1] = bpfState[0]; bpfState[0] = s;
|
||||||
|
bpfState[3] = bpfState[2]; bpfState[2] = filtered;
|
||||||
|
|
||||||
|
// Delay-and-multiply frequency discriminator
|
||||||
|
const delayed = delayBuf[delayIdx];
|
||||||
|
delayBuf[delayIdx] = filtered;
|
||||||
|
delayIdx = (delayIdx + 1) % DELAY;
|
||||||
|
|
||||||
|
const disc = filtered * delayed;
|
||||||
|
|
||||||
|
// Moving-average LPF
|
||||||
|
avgSum += disc - avgBuf[avgIdx];
|
||||||
|
avgBuf[avgIdx] = disc;
|
||||||
|
avgIdx = (avgIdx + 1) % avgLen;
|
||||||
|
|
||||||
|
// mark (1200 Hz) → negative, space (2200 Hz) → positive
|
||||||
|
const bit = avgSum < 0 ? 1 : 0;
|
||||||
|
|
||||||
|
// PLL clock recovery
|
||||||
|
if (bit !== lastTone) {
|
||||||
|
lastTone = bit;
|
||||||
|
const error = bitPhase - samplesPerBit / 2;
|
||||||
|
bitPhase -= PLL_GAIN * error;
|
||||||
|
}
|
||||||
|
|
||||||
|
bitPhase--;
|
||||||
|
if (bitPhase <= 0) {
|
||||||
|
bitPhase += samplesPerBit;
|
||||||
|
dbgBits++;
|
||||||
|
processBit(bit);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbgSamples++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processBit(rawBit) {
|
||||||
|
// NRZI decode: no transition = 1, transition = 0
|
||||||
|
const decodedBit = (rawBit === prevSampledBit) ? 1 : 0;
|
||||||
|
prevSampledBit = rawBit;
|
||||||
|
|
||||||
|
if (decodedBit === 1) {
|
||||||
|
// Don't push yet — buffer in ones counter until we know
|
||||||
|
// these aren't part of a flag, stuff, or abort sequence
|
||||||
|
ones++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodedBit === 0
|
||||||
|
if (ones >= 7) {
|
||||||
|
// Abort sequence — reset
|
||||||
|
inFrame = false;
|
||||||
|
frameBits = [];
|
||||||
|
ones = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ones === 6) {
|
||||||
|
// Flag (01111110) — frame boundary; the 6 ones are flag bits, not data
|
||||||
|
dbgFlags++;
|
||||||
|
if (inFrame && frameBits.length >= 136) {
|
||||||
|
dbgFrameAttempts++;
|
||||||
|
const result = bitsToBytes(frameBits);
|
||||||
|
if (result) {
|
||||||
|
if (result.crcOk) dbgFramesOk++;
|
||||||
|
frames.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frameBits = [];
|
||||||
|
inFrame = true;
|
||||||
|
ones = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ones === 5) {
|
||||||
|
// Bit stuffing — flush the 5 data ones, discard the stuffed zero
|
||||||
|
if (inFrame) {
|
||||||
|
for (let k = 0; k < 5; k++) frameBits.push(1);
|
||||||
|
}
|
||||||
|
ones = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal data: flush buffered ones then push the zero
|
||||||
|
if (inFrame) {
|
||||||
|
for (let k = 0; k < ones; k++) frameBits.push(1);
|
||||||
|
frameBits.push(0);
|
||||||
|
}
|
||||||
|
ones = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitsToBytes(bits) {
|
||||||
|
const byteLen = Math.floor(bits.length / 8);
|
||||||
|
if (byteLen < 17) return null;
|
||||||
|
const bytes = new Uint8Array(byteLen);
|
||||||
|
for (let i = 0; i < byteLen; i++) {
|
||||||
|
let b = 0;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
b |= (bits[i * 8 + j] << j);
|
||||||
|
}
|
||||||
|
bytes[i] = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify FCS (last 2 bytes)
|
||||||
|
const payload = bytes.subarray(0, byteLen - 2);
|
||||||
|
const fcs = bytes[byteLen - 2] | (bytes[byteLen - 1] << 8);
|
||||||
|
const computed = crc16ccitt(payload);
|
||||||
|
if (computed !== fcs) {
|
||||||
|
dbgCrcFails++;
|
||||||
|
// Try to decode addresses for diagnostics
|
||||||
|
let addrInfo = "";
|
||||||
|
if (payload.length >= 14) {
|
||||||
|
const dstCall = Array.from(payload.subarray(0, 6)).map(b => String.fromCharCode(b >> 1)).join("").trim();
|
||||||
|
const srcCall = Array.from(payload.subarray(7, 13)).map(b => String.fromCharCode(b >> 1)).join("").trim();
|
||||||
|
addrInfo = ` dst="${dstCall}" src="${srcCall}"`;
|
||||||
|
}
|
||||||
|
console.debug("[APRS-DBG] CRC fail:", byteLen, "bytes, fcs=0x" + fcs.toString(16),
|
||||||
|
"computed=0x" + computed.toString(16), "bits:", bits.length, addrInfo,
|
||||||
|
"hex:", Array.from(bytes.subarray(0, Math.min(20, byteLen))).map(b => b.toString(16).padStart(2, "0")).join(" "));
|
||||||
|
// Return as suspect frame for display
|
||||||
|
return { payload, crcOk: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, crcOk: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function processBuffer(samples) {
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
processSample(samples[i]);
|
||||||
|
}
|
||||||
|
// Periodic debug log every 3 seconds
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - dbgLastLog >= 3000) {
|
||||||
|
console.log("[APRS-DBG] samples:", dbgSamples, "bits:", dbgBits, "flags:", dbgFlags,
|
||||||
|
"frameAttempts:", dbgFrameAttempts, "crcFails:", dbgCrcFails, "ok:", dbgFramesOk);
|
||||||
|
dbgLastLog = now;
|
||||||
|
}
|
||||||
|
const result = frames.splice(0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processBuffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
// AX.25 address extraction
|
||||||
|
function decodeAX25Address(bytes, offset) {
|
||||||
|
let call = "";
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const ch = bytes[offset + i] >> 1;
|
||||||
|
if (ch > 32) call += String.fromCharCode(ch);
|
||||||
|
}
|
||||||
|
call = call.trimEnd();
|
||||||
|
const ssid = (bytes[offset + 6] >> 1) & 0x0F;
|
||||||
|
const last = (bytes[offset + 6] & 0x01) === 1;
|
||||||
|
return { call, ssid, last };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAX25(frame) {
|
||||||
|
if (frame.length < 16) return null;
|
||||||
|
const dest = decodeAX25Address(frame, 0);
|
||||||
|
const src = decodeAX25Address(frame, 7);
|
||||||
|
|
||||||
|
let offset = 14;
|
||||||
|
const digis = [];
|
||||||
|
let lastAddr = src.last;
|
||||||
|
while (!lastAddr && offset + 7 <= frame.length) {
|
||||||
|
const digi = decodeAX25Address(frame, offset);
|
||||||
|
digis.push(digi);
|
||||||
|
lastAddr = digi.last;
|
||||||
|
offset += 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset + 2 > frame.length) return null;
|
||||||
|
const control = frame[offset];
|
||||||
|
const pid = frame[offset + 1];
|
||||||
|
const info = frame.subarray(offset + 2);
|
||||||
|
|
||||||
|
return { src, dest, digis, control, pid, info };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAPRS(ax25) {
|
||||||
|
const srcCall = ax25.src.ssid ? `${ax25.src.call}-${ax25.src.ssid}` : ax25.src.call;
|
||||||
|
const destCall = ax25.dest.ssid ? `${ax25.dest.call}-${ax25.dest.ssid}` : ax25.dest.call;
|
||||||
|
const path = ax25.digis.map((d) => d.ssid ? `${d.call}-${d.ssid}` : d.call).join(",");
|
||||||
|
const infoStr = new TextDecoder().decode(ax25.info);
|
||||||
|
|
||||||
|
let type = "Unknown";
|
||||||
|
if (infoStr.length > 0) {
|
||||||
|
const dt = infoStr[0];
|
||||||
|
if (dt === "!" || dt === "=" || dt === "/" || dt === "@") type = "Position";
|
||||||
|
else if (dt === ":") type = "Message";
|
||||||
|
else if (dt === ">") type = "Status";
|
||||||
|
else if (dt === "T") type = "Telemetry";
|
||||||
|
else if (dt === ";") type = "Object";
|
||||||
|
else if (dt === ")") type = "Item";
|
||||||
|
else if (dt === "`" || dt === "'") type = "Mic-E";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { srcCall, destCall, path, info: infoStr, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAprsPacket(pkt) {
|
||||||
|
const tag = pkt.crcOk ? "[APRS]" : "[APRS-CRC-FAIL]";
|
||||||
|
console.log(tag, `${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}`, pkt);
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "aprs-packet";
|
||||||
|
if (!pkt.crcOk) row.style.opacity = "0.5";
|
||||||
|
const now = new Date();
|
||||||
|
const ts = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const crcTag = pkt.crcOk ? "" : ' <span style="color:var(--accent-red);">[CRC]</span>';
|
||||||
|
row.innerHTML = `<span class="aprs-time">${ts}</span><span class="aprs-call">${pkt.srcCall}</span>>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: <span title="${pkt.type}">${pkt.info}</span>${crcTag}`;
|
||||||
|
aprsPacketsEl.prepend(row);
|
||||||
|
while (aprsPacketsEl.children.length > APRS_MAX_PACKETS) {
|
||||||
|
aprsPacketsEl.removeChild(aprsPacketsEl.lastChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAprs() {
|
||||||
|
if (aprsActive) { stopAprs(); return; }
|
||||||
|
if (!hasWebCodecs) {
|
||||||
|
aprsStatus.textContent = "Requires Chrome/Edge";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
aprsWs = new WebSocket(`${proto}//${location.host}/audio`);
|
||||||
|
aprsWs.binaryType = "arraybuffer";
|
||||||
|
aprsStatus.textContent = "Connecting…";
|
||||||
|
|
||||||
|
let demodulator = null;
|
||||||
|
|
||||||
|
aprsWs.onopen = () => {
|
||||||
|
aprsStatus.textContent = "Waiting for stream info…";
|
||||||
|
};
|
||||||
|
|
||||||
|
aprsWs.onmessage = (evt) => {
|
||||||
|
if (typeof evt.data === "string") {
|
||||||
|
try {
|
||||||
|
const info = JSON.parse(evt.data);
|
||||||
|
const sr = info.sample_rate || 48000;
|
||||||
|
const ch = info.channels || 1;
|
||||||
|
aprsAudioCtx = new AudioContext({ sampleRate: sr });
|
||||||
|
demodulator = createDemodulator(sr);
|
||||||
|
|
||||||
|
let aprsFrameCount = 0;
|
||||||
|
aprsDecoder = new AudioDecoder({
|
||||||
|
output: (frame) => {
|
||||||
|
if (aprsFrameCount++ === 0) {
|
||||||
|
console.log("[APRS-DBG] First PCM frame:", frame.numberOfFrames, "samples,", frame.numberOfChannels, "ch, format:", frame.format, "sr:", frame.sampleRate);
|
||||||
|
}
|
||||||
|
const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels);
|
||||||
|
frame.copyTo(buf, { planeIndex: 0 });
|
||||||
|
// Use first channel only
|
||||||
|
let mono;
|
||||||
|
if (frame.numberOfChannels === 1) {
|
||||||
|
mono = buf;
|
||||||
|
} else {
|
||||||
|
mono = new Float32Array(frame.numberOfFrames);
|
||||||
|
for (let i = 0; i < frame.numberOfFrames; i++) {
|
||||||
|
mono[i] = buf[i * frame.numberOfChannels];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const frames = demodulator.processBuffer(mono);
|
||||||
|
for (const result of frames) {
|
||||||
|
const ax25 = parseAX25(result.payload);
|
||||||
|
if (!ax25) continue;
|
||||||
|
const pkt = parseAPRS(ax25);
|
||||||
|
pkt.crcOk = result.crcOk;
|
||||||
|
addAprsPacket(pkt);
|
||||||
|
}
|
||||||
|
frame.close();
|
||||||
|
},
|
||||||
|
error: (e) => { console.error("APRS AudioDecoder error", e); }
|
||||||
|
});
|
||||||
|
aprsDecoder.configure({
|
||||||
|
codec: "opus",
|
||||||
|
sampleRate: sr,
|
||||||
|
numberOfChannels: ch,
|
||||||
|
});
|
||||||
|
|
||||||
|
aprsActive = true;
|
||||||
|
aprsToggleBtn.style.borderColor = "#00d17f";
|
||||||
|
aprsToggleBtn.style.color = "#00d17f";
|
||||||
|
aprsToggleBtn.textContent = "Stop APRS";
|
||||||
|
aprsStatus.textContent = "Listening…";
|
||||||
|
} catch (e) {
|
||||||
|
console.error("APRS stream info error", e);
|
||||||
|
aprsStatus.textContent = "Error";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary Opus data
|
||||||
|
if (!aprsDecoder) return;
|
||||||
|
try {
|
||||||
|
aprsDecoder.decode(new EncodedAudioChunk({
|
||||||
|
type: "key",
|
||||||
|
timestamp: performance.now() * 1000,
|
||||||
|
data: new Uint8Array(evt.data),
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore individual decode errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
aprsWs.onclose = () => {
|
||||||
|
stopAprs();
|
||||||
|
};
|
||||||
|
|
||||||
|
aprsWs.onerror = () => {
|
||||||
|
aprsStatus.textContent = "Connection error";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAprs() {
|
||||||
|
aprsActive = false;
|
||||||
|
if (aprsWs) { aprsWs.close(); aprsWs = null; }
|
||||||
|
if (aprsAudioCtx) { aprsAudioCtx.close(); aprsAudioCtx = null; }
|
||||||
|
if (aprsDecoder) {
|
||||||
|
try { aprsDecoder.close(); } catch (e) {}
|
||||||
|
aprsDecoder = null;
|
||||||
|
}
|
||||||
|
aprsToggleBtn.style.borderColor = "";
|
||||||
|
aprsToggleBtn.style.color = "";
|
||||||
|
aprsToggleBtn.textContent = "Start APRS";
|
||||||
|
aprsStatus.textContent = "Stopped";
|
||||||
|
}
|
||||||
|
|
||||||
|
aprsToggleBtn.addEventListener("click", startAprs);
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// --- CW (Morse) Decoder Plugin ---
|
||||||
|
const cwToggleBtn = document.getElementById("cw-toggle-btn");
|
||||||
|
const cwStatusEl = document.getElementById("cw-status");
|
||||||
|
const cwOutputEl = document.getElementById("cw-output");
|
||||||
|
const cwWpmInput = document.getElementById("cw-wpm");
|
||||||
|
const cwToneInput = document.getElementById("cw-tone");
|
||||||
|
const cwThresholdInput = document.getElementById("cw-threshold");
|
||||||
|
const cwThresholdVal = document.getElementById("cw-threshold-val");
|
||||||
|
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
|
const CW_MAX_LINES = 200;
|
||||||
|
|
||||||
|
let cwActive = false;
|
||||||
|
let cwWs = null;
|
||||||
|
let cwAudioCtx = null;
|
||||||
|
let cwDecoder = null;
|
||||||
|
|
||||||
|
// ITU Morse code lookup
|
||||||
|
const MORSE_TABLE = {
|
||||||
|
".-": "A", "-...": "B", "-.-.": "C", "-..": "D", ".": "E",
|
||||||
|
"..-.": "F", "--.": "G", "....": "H", "..": "I", ".---": "J",
|
||||||
|
"-.-": "K", ".-..": "L", "--": "M", "-.": "N", "---": "O",
|
||||||
|
".--.": "P", "--.-": "Q", ".-.": "R", "...": "S", "-": "T",
|
||||||
|
"..-": "U", "...-": "V", ".--": "W", "-..-": "X", "-.--": "Y",
|
||||||
|
"--..": "Z",
|
||||||
|
"-----": "0", ".----": "1", "..---": "2", "...--": "3", "....-": "4",
|
||||||
|
".....": "5", "-....": "6", "--...": "7", "---..": "8", "----.": "9",
|
||||||
|
".-.-.-": ".", "--..--": ",", "..--..": "?", ".----.": "'",
|
||||||
|
"-.-.--": "!", "-..-.": "/", "-.--.": "(", "-.--.-": ")",
|
||||||
|
".-...": "&", "---...": ":", "-.-.-.": ";", "-...-": "=",
|
||||||
|
".-.-.": "+", "-....-": "-", "..--.-": "_", ".-..-.": "\"",
|
||||||
|
"...-..-": "$", ".--.-.": "@",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update threshold display
|
||||||
|
cwThresholdInput.addEventListener("input", () => {
|
||||||
|
cwThresholdVal.textContent = (cwThresholdInput.value / 100).toFixed(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
function createCwDecoder(sampleRate) {
|
||||||
|
let wpm = parseInt(cwWpmInput.value, 10) || 15;
|
||||||
|
let toneFreq = parseInt(cwToneInput.value, 10) || 700;
|
||||||
|
let threshold = (parseInt(cwThresholdInput.value, 10) || 5) / 100;
|
||||||
|
|
||||||
|
// Goertzel parameters
|
||||||
|
const windowMs = 50; // 50ms analysis window
|
||||||
|
const windowSize = Math.round(sampleRate * windowMs / 1000);
|
||||||
|
const k = Math.round(toneFreq * windowSize / sampleRate);
|
||||||
|
const omega = (2 * Math.PI * k) / windowSize;
|
||||||
|
const coeff = 2 * Math.cos(omega);
|
||||||
|
|
||||||
|
let sampleBuf = new Float32Array(windowSize);
|
||||||
|
let sampleIdx = 0;
|
||||||
|
|
||||||
|
// Tone state tracking
|
||||||
|
let toneOn = false;
|
||||||
|
let toneOnAt = 0;
|
||||||
|
let toneOffAt = 0;
|
||||||
|
let currentSymbol = ""; // accumulates dits/dahs for current character
|
||||||
|
let decoded = "";
|
||||||
|
let lastAppendTime = 0;
|
||||||
|
|
||||||
|
// Timing: 1 unit = 1200/WPM ms
|
||||||
|
function unitMs() { return 1200 / wpm; }
|
||||||
|
|
||||||
|
function goertzelDetect(buf) {
|
||||||
|
let s0 = 0, s1 = 0, s2 = 0;
|
||||||
|
let totalEnergy = 0;
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
s0 = coeff * s1 - s2 + buf[i];
|
||||||
|
s2 = s1;
|
||||||
|
s1 = s0;
|
||||||
|
totalEnergy += buf[i] * buf[i];
|
||||||
|
}
|
||||||
|
const toneEnergy = (s1 * s1 + s2 * s2 - coeff * s1 * s2) / (buf.length * buf.length);
|
||||||
|
const avgEnergy = totalEnergy / buf.length;
|
||||||
|
if (avgEnergy < 1e-10) return false;
|
||||||
|
return (toneEnergy / avgEnergy) > threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processWindow() {
|
||||||
|
const detected = goertzelDetect(sampleBuf);
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
// Update signal indicator
|
||||||
|
if (detected) {
|
||||||
|
cwSignalIndicator.className = "cw-signal-on";
|
||||||
|
} else {
|
||||||
|
cwSignalIndicator.className = "cw-signal-off";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detected && !toneOn) {
|
||||||
|
// Tone just turned on
|
||||||
|
toneOn = true;
|
||||||
|
const offDuration = now - toneOffAt;
|
||||||
|
if (toneOffAt > 0) {
|
||||||
|
const u = unitMs();
|
||||||
|
if (offDuration > u * 5) {
|
||||||
|
// Word gap (7 units, use 5 as threshold)
|
||||||
|
if (currentSymbol) {
|
||||||
|
const ch = MORSE_TABLE[currentSymbol] || "?";
|
||||||
|
appendChar(ch);
|
||||||
|
currentSymbol = "";
|
||||||
|
}
|
||||||
|
appendChar(" ");
|
||||||
|
} else if (offDuration > u * 2) {
|
||||||
|
// Character gap (3 units, use 2 as threshold)
|
||||||
|
if (currentSymbol) {
|
||||||
|
const ch = MORSE_TABLE[currentSymbol] || "?";
|
||||||
|
appendChar(ch);
|
||||||
|
currentSymbol = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// else: inter-element gap, do nothing
|
||||||
|
}
|
||||||
|
toneOnAt = now;
|
||||||
|
} else if (!detected && toneOn) {
|
||||||
|
// Tone just turned off
|
||||||
|
toneOn = false;
|
||||||
|
const onDuration = now - toneOnAt;
|
||||||
|
const u = unitMs();
|
||||||
|
if (onDuration > u * 2) {
|
||||||
|
currentSymbol += "-"; // dah (3 units, use 2 as threshold)
|
||||||
|
} else {
|
||||||
|
currentSymbol += "."; // dit
|
||||||
|
}
|
||||||
|
toneOffAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush pending character after long silence
|
||||||
|
if (!toneOn && currentSymbol && toneOffAt > 0) {
|
||||||
|
const silenceDuration = now - toneOffAt;
|
||||||
|
if (silenceDuration > unitMs() * 5) {
|
||||||
|
const ch = MORSE_TABLE[currentSymbol] || "?";
|
||||||
|
appendChar(ch);
|
||||||
|
currentSymbol = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendChar(ch) {
|
||||||
|
decoded += ch;
|
||||||
|
// Append to output element
|
||||||
|
const now = Date.now();
|
||||||
|
if (!cwOutputEl.lastElementChild || now - lastAppendTime > 10000 || ch === "\n") {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "cw-line";
|
||||||
|
cwOutputEl.appendChild(line);
|
||||||
|
}
|
||||||
|
lastAppendTime = now;
|
||||||
|
const lastLine = cwOutputEl.lastElementChild;
|
||||||
|
if (lastLine) {
|
||||||
|
lastLine.textContent += ch;
|
||||||
|
}
|
||||||
|
// Cap lines
|
||||||
|
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
||||||
|
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
||||||
|
}
|
||||||
|
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processSamples(mono) {
|
||||||
|
for (let i = 0; i < mono.length; i++) {
|
||||||
|
sampleBuf[sampleIdx++] = mono[i];
|
||||||
|
if (sampleIdx >= windowSize) {
|
||||||
|
processWindow();
|
||||||
|
sampleIdx = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConfig() {
|
||||||
|
wpm = parseInt(cwWpmInput.value, 10) || 15;
|
||||||
|
threshold = (parseInt(cwThresholdInput.value, 10) || 5) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processSamples, updateConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCw() {
|
||||||
|
if (cwActive) { stopCw(); return; }
|
||||||
|
if (!hasWebCodecs) {
|
||||||
|
cwStatusEl.textContent = "Requires Chrome/Edge";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
cwWs = new WebSocket(`${proto}//${location.host}/audio`);
|
||||||
|
cwWs.binaryType = "arraybuffer";
|
||||||
|
cwStatusEl.textContent = "Connecting…";
|
||||||
|
|
||||||
|
let decoderEngine = null;
|
||||||
|
|
||||||
|
cwWs.onopen = () => {
|
||||||
|
cwStatusEl.textContent = "Waiting for stream info…";
|
||||||
|
};
|
||||||
|
|
||||||
|
cwWs.onmessage = (evt) => {
|
||||||
|
if (typeof evt.data === "string") {
|
||||||
|
try {
|
||||||
|
const info = JSON.parse(evt.data);
|
||||||
|
const sr = info.sample_rate || 48000;
|
||||||
|
const ch = info.channels || 1;
|
||||||
|
cwAudioCtx = new AudioContext({ sampleRate: sr });
|
||||||
|
decoderEngine = createCwDecoder(sr);
|
||||||
|
|
||||||
|
let cwFrameCount = 0;
|
||||||
|
cwDecoder = new AudioDecoder({
|
||||||
|
output: (frame) => {
|
||||||
|
if (cwFrameCount++ === 0) {
|
||||||
|
console.log("[CW-DBG] First PCM frame:", frame.numberOfFrames, "samples,", frame.numberOfChannels, "ch, format:", frame.format, "sr:", frame.sampleRate);
|
||||||
|
}
|
||||||
|
const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels);
|
||||||
|
frame.copyTo(buf, { planeIndex: 0 });
|
||||||
|
let mono;
|
||||||
|
if (frame.numberOfChannels === 1) {
|
||||||
|
mono = buf;
|
||||||
|
} else {
|
||||||
|
mono = new Float32Array(frame.numberOfFrames);
|
||||||
|
for (let i = 0; i < frame.numberOfFrames; i++) {
|
||||||
|
mono[i] = buf[i * frame.numberOfChannels];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decoderEngine.processSamples(mono);
|
||||||
|
frame.close();
|
||||||
|
},
|
||||||
|
error: (e) => { console.error("CW AudioDecoder error", e); }
|
||||||
|
});
|
||||||
|
cwDecoder.configure({
|
||||||
|
codec: "opus",
|
||||||
|
sampleRate: sr,
|
||||||
|
numberOfChannels: ch,
|
||||||
|
});
|
||||||
|
|
||||||
|
cwActive = true;
|
||||||
|
cwToggleBtn.style.borderColor = "#00d17f";
|
||||||
|
cwToggleBtn.style.color = "#00d17f";
|
||||||
|
cwToggleBtn.textContent = "Stop CW";
|
||||||
|
cwStatusEl.textContent = "Listening…";
|
||||||
|
|
||||||
|
// Allow live config updates
|
||||||
|
cwWpmInput.addEventListener("change", decoderEngine.updateConfig);
|
||||||
|
cwThresholdInput.addEventListener("input", decoderEngine.updateConfig);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW stream info error", e);
|
||||||
|
cwStatusEl.textContent = "Error";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary Opus data
|
||||||
|
if (!cwDecoder) return;
|
||||||
|
try {
|
||||||
|
cwDecoder.decode(new EncodedAudioChunk({
|
||||||
|
type: "key",
|
||||||
|
timestamp: performance.now() * 1000,
|
||||||
|
data: new Uint8Array(evt.data),
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore individual decode errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cwWs.onclose = () => {
|
||||||
|
stopCw();
|
||||||
|
};
|
||||||
|
|
||||||
|
cwWs.onerror = () => {
|
||||||
|
cwStatusEl.textContent = "Connection error";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCw() {
|
||||||
|
cwActive = false;
|
||||||
|
if (cwWs) { cwWs.close(); cwWs = null; }
|
||||||
|
if (cwAudioCtx) { cwAudioCtx.close(); cwAudioCtx = null; }
|
||||||
|
if (cwDecoder) {
|
||||||
|
try { cwDecoder.close(); } catch (e) {}
|
||||||
|
cwDecoder = null;
|
||||||
|
}
|
||||||
|
cwToggleBtn.style.borderColor = "";
|
||||||
|
cwToggleBtn.style.color = "";
|
||||||
|
cwToggleBtn.textContent = "Start CW";
|
||||||
|
cwStatusEl.textContent = "Stopped";
|
||||||
|
cwSignalIndicator.className = "cw-signal-off";
|
||||||
|
}
|
||||||
|
|
||||||
|
cwToggleBtn.addEventListener("click", startCw);
|
||||||
@@ -121,6 +121,7 @@
|
|||||||
<div class="sub-tab-bar">
|
<div class="sub-tab-bar">
|
||||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
||||||
<button class="sub-tab" data-subtab="aprs">APRS</button>
|
<button class="sub-tab" data-subtab="aprs">APRS</button>
|
||||||
|
<button class="sub-tab" data-subtab="cw">CW</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="subtab-overview" class="sub-tab-panel">
|
<div id="subtab-overview" class="sub-tab-panel">
|
||||||
<div class="plugin-item">
|
<div class="plugin-item">
|
||||||
@@ -129,6 +130,12 @@
|
|||||||
Decodes APRS packets from RX audio using Bell 202 AFSK (1200 baud).
|
Decodes APRS packets from RX audio using Bell 202 AFSK (1200 baud).
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="plugin-item">
|
||||||
|
<strong>CW Decoder</strong>
|
||||||
|
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||||
|
Decodes CW (Morse code) from RX audio.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="aprs-controls">
|
<div class="aprs-controls">
|
||||||
@@ -137,6 +144,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="aprs-packets"></div>
|
<div id="aprs-packets"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="subtab-cw" class="sub-tab-panel" style="display:none;">
|
||||||
|
<div class="cw-controls">
|
||||||
|
<button id="cw-toggle-btn" type="button">Start CW</button>
|
||||||
|
<small id="cw-status" style="color:var(--text-muted);">Stopped</small>
|
||||||
|
<div id="cw-signal-indicator" class="cw-signal-off"></div>
|
||||||
|
</div>
|
||||||
|
<div class="cw-config">
|
||||||
|
<label>WPM <input type="number" id="cw-wpm" min="5" max="40" value="15" /></label>
|
||||||
|
<label>Tone (Hz) <input type="number" id="cw-tone" min="300" max="1200" value="700" /></label>
|
||||||
|
<label>Threshold <input type="range" id="cw-threshold" min="1" max="50" value="5" /><small id="cw-threshold-val">0.05</small></label>
|
||||||
|
</div>
|
||||||
|
<div id="cw-output"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||||
<table class="about-table">
|
<table class="about-table">
|
||||||
@@ -157,5 +177,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
|
<script src="/aprs.js"></script>
|
||||||
|
<script src="/cw.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -219,6 +219,15 @@ small { color: var(--text-muted); }
|
|||||||
.aprs-call { color: var(--accent-green); font-weight: 600; }
|
.aprs-call { color: var(--accent-green); font-weight: 600; }
|
||||||
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
|
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
|
||||||
|
|
||||||
|
.cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
|
||||||
|
.cw-config { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||||
|
.cw-config label { display: flex; flex-direction: column; gap: 0.2rem; color: var(--text-muted); font-size: 0.82rem; }
|
||||||
|
.cw-config input[type="number"] { width: 5rem; padding: 0.3rem 0.4rem; font-size: 0.9rem; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); color: var(--text); }
|
||||||
|
#cw-output { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; padding: 0.4rem 0.5rem; min-height: 60px; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.cw-line { line-height: 1.5; }
|
||||||
|
.cw-signal-on { width: 10px; height: 10px; border-radius: 50%; background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); flex-shrink: 0; }
|
||||||
|
.cw-signal-off { width: 10px; height: 10px; border-radius: 50%; background: var(--border-light); flex-shrink: 0; }
|
||||||
|
|
||||||
button:focus-visible, input:focus-visible, select:focus-visible {
|
button:focus-visible, input:focus-visible, select:focus-visible {
|
||||||
outline: 2px solid var(--accent-green);
|
outline: 2px solid var(--accent-green);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
|||||||
@@ -243,7 +243,9 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(favicon)
|
.service(favicon)
|
||||||
.service(logo)
|
.service(logo)
|
||||||
.service(style_css)
|
.service(style_css)
|
||||||
.service(app_js);
|
.service(app_js)
|
||||||
|
.service(aprs_js)
|
||||||
|
.service(cw_js);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
@@ -281,6 +283,20 @@ async fn app_js() -> impl Responder {
|
|||||||
.body(status::APP_JS)
|
.body(status::APP_JS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/aprs.js")]
|
||||||
|
async fn aprs_js() -> impl Responder {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8"))
|
||||||
|
.body(status::APRS_JS)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/cw.js")]
|
||||||
|
async fn cw_js() -> impl Responder {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8"))
|
||||||
|
.body(status::CW_JS)
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_command(
|
async fn send_command(
|
||||||
rig_tx: &mpsc::Sender<RigRequest>,
|
rig_tx: &mpsc::Sender<RigRequest>,
|
||||||
cmd: RigCommand,
|
cmd: RigCommand,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|||||||
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
|
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
|
||||||
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
||||||
pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
||||||
|
pub const APRS_JS: &str = include_str!("../assets/web/aprs.js");
|
||||||
|
pub const CW_JS: &str = include_str!("../assets/web/cw.js");
|
||||||
|
|
||||||
pub fn index_html() -> String {
|
pub fn index_html() -> String {
|
||||||
INDEX_HTML
|
INDEX_HTML
|
||||||
|
|||||||
Reference in New Issue
Block a user