[refactor](trx-frontend-http): remove browser-side APRS/CW decoding
Server-side decoding makes client-side decoders redundant. Remove ~1000 lines of browser-side Bell 202 AFSK, AX.25/APRS parsing, and Goertzel CW decoding. The frontend now relies solely on the /decode SSE endpoint for decoded data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -145,25 +145,20 @@
|
||||
</div>
|
||||
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
|
||||
<div class="aprs-controls">
|
||||
<button id="aprs-toggle-btn" type="button">Start APRS</button>
|
||||
<button id="aprs-clear-btn" type="button">Clear</button>
|
||||
<small id="aprs-status" style="color:var(--text-muted);">Stopped</small>
|
||||
<small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div id="aprs-packets"></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>
|
||||
<button id="cw-clear-btn" type="button">Clear</button>
|
||||
<small id="cw-status" style="color:var(--text-muted);">Stopped</small>
|
||||
<small id="cw-status" style="color:var(--text-muted);">Waiting for server decode</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" readonly /></label>
|
||||
<label class="cw-auto-label"><input type="checkbox" id="cw-wpm-auto" checked /> Auto</label>
|
||||
<label>Tone (Hz) <input type="number" id="cw-tone" min="300" max="1200" value="700" readonly /></label>
|
||||
<label class="cw-auto-label"><input type="checkbox" id="cw-tone-auto" checked /> Auto</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>
|
||||
|
||||
@@ -1,437 +1,11 @@
|
||||
// --- APRS Decoder Plugin ---
|
||||
const aprsToggleBtn = document.getElementById("aprs-toggle-btn");
|
||||
// --- APRS Decoder Plugin (server-side decode) ---
|
||||
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;
|
||||
|
||||
// Persistent packet history
|
||||
let aprsPacketHistory = loadSetting("aprsPackets", []);
|
||||
|
||||
// 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 mark/space correlation detector (non-coherent FSK matched filter).
|
||||
function createDemodulator(sampleRate, windowFactor) {
|
||||
const BAUD = 1200;
|
||||
const MARK = 1200;
|
||||
const SPACE = 2200;
|
||||
const samplesPerBit = sampleRate / BAUD;
|
||||
const corrFactor = windowFactor || 1.0;
|
||||
|
||||
// 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;
|
||||
|
||||
// Mark/space correlation detector
|
||||
// Mix input with cos/sin reference oscillators at mark and space frequencies,
|
||||
// then integrate over one bit period to get I/Q energy at each frequency.
|
||||
const markPhaseInc = 2 * Math.PI * MARK / sampleRate;
|
||||
const spacePhaseInc = 2 * Math.PI * SPACE / sampleRate;
|
||||
let markPhase = 0;
|
||||
let spacePhase = 0;
|
||||
|
||||
// Sliding-window matched filter
|
||||
const corrLen = Math.max(2, Math.round(samplesPerBit * corrFactor));
|
||||
const markIBuf = new Float32Array(corrLen);
|
||||
const markQBuf = new Float32Array(corrLen);
|
||||
const spaceIBuf = new Float32Array(corrLen);
|
||||
const spaceQBuf = new Float32Array(corrLen);
|
||||
let corrIdx = 0;
|
||||
let markISum = 0, markQSum = 0, spaceISum = 0, spaceQSum = 0;
|
||||
|
||||
// Clock recovery (PLL)
|
||||
let lastBit = 0;
|
||||
let bitPhase = 0;
|
||||
const PLL_GAIN = 0.4;
|
||||
|
||||
// NRZI state
|
||||
let prevSampledBit = 0;
|
||||
|
||||
// HDLC state
|
||||
let ones = 0;
|
||||
let frameBits = [];
|
||||
let inFrame = false;
|
||||
|
||||
const frames = [];
|
||||
|
||||
function resetState() {
|
||||
markPhase = 0;
|
||||
spacePhase = 0;
|
||||
markIBuf.fill(0); markQBuf.fill(0);
|
||||
spaceIBuf.fill(0); spaceQBuf.fill(0);
|
||||
corrIdx = 0;
|
||||
markISum = 0; markQSum = 0;
|
||||
spaceISum = 0; spaceQSum = 0;
|
||||
lastBit = 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;
|
||||
}
|
||||
|
||||
// Mix with mark/space reference oscillators
|
||||
const mI = s * Math.cos(markPhase);
|
||||
const mQ = s * Math.sin(markPhase);
|
||||
const sI = s * Math.cos(spacePhase);
|
||||
const sQ = s * Math.sin(spacePhase);
|
||||
markPhase += markPhaseInc;
|
||||
spacePhase += spacePhaseInc;
|
||||
if (markPhase > 6.283185307) markPhase -= 6.283185307;
|
||||
if (spacePhase > 6.283185307) spacePhase -= 6.283185307;
|
||||
|
||||
// Sliding-window integration (matched filter over 1 bit period)
|
||||
markISum += mI - markIBuf[corrIdx];
|
||||
markQSum += mQ - markQBuf[corrIdx];
|
||||
spaceISum += sI - spaceIBuf[corrIdx];
|
||||
spaceQSum += sQ - spaceQBuf[corrIdx];
|
||||
markIBuf[corrIdx] = mI;
|
||||
markQBuf[corrIdx] = mQ;
|
||||
spaceIBuf[corrIdx] = sI;
|
||||
spaceQBuf[corrIdx] = sQ;
|
||||
corrIdx = (corrIdx + 1) % corrLen;
|
||||
|
||||
// Compare mark vs space energy (I²+Q²)
|
||||
const markEnergy = markISum * markISum + markQSum * markQSum;
|
||||
const spaceEnergy = spaceISum * spaceISum + spaceQSum * spaceQSum;
|
||||
const bit = markEnergy > spaceEnergy ? 1 : 0;
|
||||
|
||||
// PLL clock recovery
|
||||
if (bit !== lastBit) {
|
||||
lastBit = 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";
|
||||
}
|
||||
|
||||
const result = { srcCall, destCall, path, info: infoStr, type };
|
||||
|
||||
if (type === "Position") {
|
||||
const pos = parseAprsPosition(infoStr);
|
||||
if (pos) {
|
||||
result.lat = pos.lat;
|
||||
result.lon = pos.lon;
|
||||
result.symbolTable = pos.symbolTable;
|
||||
result.symbolCode = pos.symbolCode;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseAprsPosition(infoStr) {
|
||||
if (infoStr.length < 1) return null;
|
||||
const dt = infoStr[0];
|
||||
let posStr;
|
||||
|
||||
if (dt === "!" || dt === "=") {
|
||||
posStr = infoStr.substring(1);
|
||||
} else if (dt === "/" || dt === "@") {
|
||||
if (infoStr.length < 8) return null;
|
||||
posStr = infoStr.substring(8);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (posStr.length < 1) return null;
|
||||
|
||||
// Compressed format: first char is symbol table (not a digit)
|
||||
// Layout: T YYYY XXXX C [cs T] — 10 chars minimum
|
||||
const firstChar = posStr[0];
|
||||
if (firstChar < "0" || firstChar > "9") {
|
||||
return parseAprsCompressed(posStr);
|
||||
}
|
||||
|
||||
// Uncompressed: DDMM.MMN/DDDMM.MMEsYYY...
|
||||
// Need at least: 8 lat + 1 table + 9 lon + 1 code = 19 chars
|
||||
if (posStr.length < 19) return null;
|
||||
|
||||
const latStr = posStr.substring(0, 8); // DDMM.MMN
|
||||
const symbolTable = posStr[8];
|
||||
const lonStr = posStr.substring(9, 18); // DDDMM.MME
|
||||
const symbolCode = posStr[18];
|
||||
|
||||
const lat = parseAprsLat(latStr);
|
||||
const lon = parseAprsLon(lonStr);
|
||||
if (lat === null || lon === null) return null;
|
||||
|
||||
return { lat, lon, symbolTable, symbolCode };
|
||||
}
|
||||
|
||||
function parseAprsCompressed(posStr) {
|
||||
// Compressed position: SymTable(1) Lat(4) Lon(4) SymCode(1) = 10 chars min
|
||||
if (posStr.length < 10) return null;
|
||||
|
||||
const symbolTable = posStr[0];
|
||||
const latChars = posStr.substring(1, 5);
|
||||
const lonChars = posStr.substring(5, 9);
|
||||
const symbolCode = posStr[9];
|
||||
|
||||
// Base-91 decode: each char value = (ASCII - 33)
|
||||
let latVal = 0;
|
||||
let lonVal = 0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const lc = latChars.charCodeAt(i) - 33;
|
||||
const xc = lonChars.charCodeAt(i) - 33;
|
||||
if (lc < 0 || lc > 90 || xc < 0 || xc > 90) return null;
|
||||
latVal = latVal * 91 + lc;
|
||||
lonVal = lonVal * 91 + xc;
|
||||
}
|
||||
|
||||
const lat = 90 - latVal / 380926;
|
||||
const lon = -180 + lonVal / 190463;
|
||||
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||
|
||||
return {
|
||||
lat: Math.round(lat * 1e6) / 1e6,
|
||||
lon: Math.round(lon * 1e6) / 1e6,
|
||||
symbolTable,
|
||||
symbolCode,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAprsLat(s) {
|
||||
// DDMM.MMN
|
||||
if (s.length < 8) return null;
|
||||
const deg = parseInt(s.substring(0, 2), 10);
|
||||
const min = parseFloat(s.substring(2, 7));
|
||||
const ns = s[7];
|
||||
if (isNaN(deg) || isNaN(min)) return null;
|
||||
let lat = deg + min / 60;
|
||||
if (ns === "S" || ns === "s") lat = -lat;
|
||||
else if (ns !== "N" && ns !== "n") return null;
|
||||
return Math.round(lat * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
function parseAprsLon(s) {
|
||||
// DDDMM.MME
|
||||
if (s.length < 9) return null;
|
||||
const deg = parseInt(s.substring(0, 3), 10);
|
||||
const min = parseFloat(s.substring(3, 8));
|
||||
const ew = s[8];
|
||||
if (isNaN(deg) || isNaN(min)) return null;
|
||||
let lon = deg + min / 60;
|
||||
if (ew === "W" || ew === "w") lon = -lon;
|
||||
else if (ew !== "E" && ew !== "e") return null;
|
||||
return Math.round(lon * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
function escapeAprsInfo(str) {
|
||||
let out = "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
@@ -498,142 +72,6 @@ function addAprsPacket(pkt) {
|
||||
}
|
||||
}
|
||||
|
||||
function startAprs() {
|
||||
if (aprsActive) 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 demodulators = 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 });
|
||||
// Multiple decoders with different correlation window lengths
|
||||
// for robustness — different windows produce different error patterns
|
||||
demodulators = [
|
||||
createDemodulator(sr, 1.0),
|
||||
createDemodulator(sr, 0.5),
|
||||
];
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
// Run all decoders and merge results, preferring CRC-ok frames
|
||||
const seen = new Set();
|
||||
const allResults = [];
|
||||
for (const demod of demodulators) {
|
||||
for (const result of demod.processBuffer(mono)) {
|
||||
const hex = Array.from(result.payload.subarray(0, Math.min(14, result.payload.length)))
|
||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
const key = hex + ":" + result.payload.length;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
allResults.push(result);
|
||||
}
|
||||
}
|
||||
// Show CRC-ok frames first, then CRC-fail frames
|
||||
allResults.sort((a, b) => (b.crcOk ? 1 : 0) - (a.crcOk ? 1 : 0));
|
||||
for (const result of allResults) {
|
||||
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;
|
||||
saveSetting("aprsRunning", 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(false);
|
||||
};
|
||||
|
||||
aprsWs.onerror = () => {
|
||||
aprsStatus.textContent = "Connection error";
|
||||
};
|
||||
}
|
||||
|
||||
function stopAprs(explicit) {
|
||||
aprsActive = false;
|
||||
if (explicit) saveSetting("aprsRunning", 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", () => {
|
||||
if (aprsActive) { stopAprs(true); } else { startAprs(); }
|
||||
});
|
||||
|
||||
document.getElementById("aprs-clear-btn").addEventListener("click", () => {
|
||||
aprsPacketsEl.innerHTML = "";
|
||||
aprsPacketHistory = [];
|
||||
@@ -651,6 +89,7 @@ for (let i = aprsPacketHistory.length - 1; i >= 0; i--) {
|
||||
|
||||
// --- Server-side APRS decode handler ---
|
||||
window.onServerAprs = function(pkt) {
|
||||
aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket({
|
||||
srcCall: pkt.src_call,
|
||||
destCall: pkt.dest_call,
|
||||
@@ -664,19 +103,3 @@ window.onServerAprs = function(pkt) {
|
||||
symbolCode: pkt.symbol_code,
|
||||
});
|
||||
};
|
||||
|
||||
// Update status display based on server decode availability
|
||||
function updateAprsStatus() {
|
||||
if (typeof decodeConnected !== "undefined" && decodeConnected) {
|
||||
if (!aprsActive) {
|
||||
aprsStatus.textContent = "Server decode active";
|
||||
aprsToggleBtn.textContent = "Start APRS (browser)";
|
||||
}
|
||||
}
|
||||
}
|
||||
setInterval(updateAprsStatus, 2000);
|
||||
|
||||
// Auto-start APRS if it was running before page refresh (browser fallback)
|
||||
if (loadSetting("aprsRunning", false) && hasWebCodecs) {
|
||||
startAprs();
|
||||
}
|
||||
|
||||
@@ -1,459 +1,11 @@
|
||||
// --- CW (Morse) Decoder Plugin ---
|
||||
const cwToggleBtn = document.getElementById("cw-toggle-btn");
|
||||
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
||||
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 cwWpmAutoCheck = document.getElementById("cw-wpm-auto");
|
||||
const cwToneAutoCheck = document.getElementById("cw-tone-auto");
|
||||
const CW_MAX_LINES = 200;
|
||||
|
||||
// Restore saved CW settings
|
||||
cwWpmInput.value = loadSetting("cwWpm", 15);
|
||||
cwToneInput.value = loadSetting("cwTone", 700);
|
||||
cwThresholdInput.value = loadSetting("cwThreshold", 5);
|
||||
cwThresholdVal.textContent = (cwThresholdInput.value / 100).toFixed(2);
|
||||
cwWpmAutoCheck.checked = loadSetting("cwWpmAuto", true);
|
||||
cwToneAutoCheck.checked = loadSetting("cwToneAuto", true);
|
||||
cwWpmInput.readOnly = cwWpmAutoCheck.checked;
|
||||
cwToneInput.readOnly = cwToneAutoCheck.checked;
|
||||
|
||||
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);
|
||||
saveSetting("cwThreshold", Number(cwThresholdInput.value));
|
||||
});
|
||||
|
||||
// Toggle readonly on WPM input based on Auto checkbox
|
||||
cwWpmAutoCheck.addEventListener("change", () => {
|
||||
cwWpmInput.readOnly = cwWpmAutoCheck.checked;
|
||||
saveSetting("cwWpmAuto", cwWpmAutoCheck.checked);
|
||||
});
|
||||
|
||||
// Toggle readonly on Tone input based on Auto checkbox
|
||||
cwToneAutoCheck.addEventListener("change", () => {
|
||||
cwToneInput.readOnly = cwToneAutoCheck.checked;
|
||||
saveSetting("cwToneAuto", cwToneAutoCheck.checked);
|
||||
});
|
||||
|
||||
// Save WPM/Tone when manually changed
|
||||
cwWpmInput.addEventListener("change", () => { saveSetting("cwWpm", Number(cwWpmInput.value)); });
|
||||
cwToneInput.addEventListener("change", () => { saveSetting("cwTone", Number(cwToneInput.value)); });
|
||||
|
||||
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 for main detector
|
||||
const windowMs = 50; // 50ms analysis window
|
||||
const windowSize = Math.round(sampleRate * windowMs / 1000);
|
||||
let k = Math.round(toneFreq * windowSize / sampleRate);
|
||||
let omega = (2 * Math.PI * k) / windowSize;
|
||||
let 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;
|
||||
|
||||
// --- Auto Tone Detection ---
|
||||
// Scan 300–1200 Hz in ~25 Hz steps
|
||||
const TONE_SCAN_LOW = 300;
|
||||
const TONE_SCAN_HIGH = 1200;
|
||||
const TONE_SCAN_STEP = 25;
|
||||
const toneScanBins = [];
|
||||
for (let f = TONE_SCAN_LOW; f <= TONE_SCAN_HIGH; f += TONE_SCAN_STEP) {
|
||||
const bk = Math.round(f * windowSize / sampleRate);
|
||||
const bOmega = (2 * Math.PI * bk) / windowSize;
|
||||
toneScanBins.push({ freq: f, coeff: 2 * Math.cos(bOmega) });
|
||||
}
|
||||
let toneStableBin = -1; // index of the bin that's been stable
|
||||
let toneStableCount = 0; // how many consecutive windows it's been the peak
|
||||
const TONE_STABLE_NEEDED = 3;
|
||||
|
||||
// --- Auto WPM Detection ---
|
||||
const onDurations = []; // rolling buffer of on-durations (ms)
|
||||
const MAX_ON_DURATIONS = 30;
|
||||
const MIN_ON_DURATIONS = 8;
|
||||
|
||||
function recomputeGoertzel(newFreq) {
|
||||
toneFreq = newFreq;
|
||||
k = Math.round(toneFreq * windowSize / sampleRate);
|
||||
omega = (2 * Math.PI * k) / windowSize;
|
||||
coeff = 2 * Math.cos(omega);
|
||||
}
|
||||
|
||||
// Timing: 1 unit = 1200/WPM ms
|
||||
function unitMs() { return 1200 / wpm; }
|
||||
|
||||
function goertzelEnergy(buf, c) {
|
||||
let s0 = 0, s1 = 0, s2 = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
s0 = c * s1 - s2 + buf[i];
|
||||
s2 = s1;
|
||||
s1 = s0;
|
||||
}
|
||||
return (s1 * s1 + s2 * s2 - c * s1 * s2) / (buf.length * buf.length);
|
||||
}
|
||||
|
||||
function goertzelDetect(buf) {
|
||||
const toneEnergy = goertzelEnergy(buf, coeff);
|
||||
let totalEnergy = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
totalEnergy += buf[i] * buf[i];
|
||||
}
|
||||
const avgEnergy = totalEnergy / buf.length;
|
||||
if (avgEnergy < 1e-10) return false;
|
||||
return (toneEnergy / avgEnergy) > threshold;
|
||||
}
|
||||
|
||||
function autoDetectTone(buf) {
|
||||
// Compute broadband energy
|
||||
let totalEnergy = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
totalEnergy += buf[i] * buf[i];
|
||||
}
|
||||
const avgEnergy = totalEnergy / buf.length;
|
||||
if (avgEnergy < 1e-10) return;
|
||||
|
||||
// Find the bin with highest energy relative to broadband
|
||||
let bestIdx = -1;
|
||||
let bestRatio = 0;
|
||||
for (let b = 0; b < toneScanBins.length; b++) {
|
||||
const e = goertzelEnergy(buf, toneScanBins[b].coeff);
|
||||
const ratio = e / avgEnergy;
|
||||
if (ratio > bestRatio) {
|
||||
bestRatio = ratio;
|
||||
bestIdx = b;
|
||||
}
|
||||
}
|
||||
|
||||
// Require the peak to exceed threshold to be meaningful
|
||||
if (bestRatio < threshold || bestIdx < 0) {
|
||||
toneStableCount = 0;
|
||||
toneStableBin = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check stability: same bin ±1
|
||||
if (toneStableBin >= 0 && Math.abs(bestIdx - toneStableBin) <= 1) {
|
||||
toneStableCount++;
|
||||
} else {
|
||||
toneStableBin = bestIdx;
|
||||
toneStableCount = 1;
|
||||
}
|
||||
|
||||
if (toneStableCount >= TONE_STABLE_NEEDED) {
|
||||
const detectedFreq = toneScanBins[toneStableBin].freq;
|
||||
if (Math.abs(detectedFreq - toneFreq) > TONE_SCAN_STEP) {
|
||||
recomputeGoertzel(detectedFreq);
|
||||
cwToneInput.value = detectedFreq;
|
||||
saveSetting("cwTone", detectedFreq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function autoDetectWpm() {
|
||||
if (onDurations.length < MIN_ON_DURATIONS) return;
|
||||
|
||||
// Sort durations ascending
|
||||
const sorted = onDurations.slice().sort((a, b) => a - b);
|
||||
|
||||
// K-means-style split: find the best boundary between dit and dah clusters
|
||||
let bestBoundary = 1;
|
||||
let bestScore = Infinity;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const cluster1 = sorted.slice(0, i);
|
||||
const cluster2 = sorted.slice(i);
|
||||
const mean1 = cluster1.reduce((a, b) => a + b, 0) / cluster1.length;
|
||||
const mean2 = cluster2.reduce((a, b) => a + b, 0) / cluster2.length;
|
||||
let score = 0;
|
||||
for (const v of cluster1) score += (v - mean1) * (v - mean1);
|
||||
for (const v of cluster2) score += (v - mean2) * (v - mean2);
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
bestBoundary = i;
|
||||
}
|
||||
}
|
||||
|
||||
// The shorter cluster is dits — take the median
|
||||
const ditCluster = sorted.slice(0, bestBoundary);
|
||||
if (ditCluster.length === 0) return;
|
||||
const ditMs = ditCluster[Math.floor(ditCluster.length / 2)];
|
||||
if (ditMs < 10) return; // too short, ignore
|
||||
|
||||
let newWpm = Math.round(1200 / ditMs);
|
||||
newWpm = Math.max(5, Math.min(40, newWpm));
|
||||
if (newWpm !== wpm) {
|
||||
wpm = newWpm;
|
||||
cwWpmInput.value = wpm;
|
||||
saveSetting("cwWpm", wpm);
|
||||
}
|
||||
}
|
||||
|
||||
function processWindow() {
|
||||
// Run auto tone detection if enabled
|
||||
if (cwToneAutoCheck.checked) {
|
||||
autoDetectTone(sampleBuf);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Collect on-duration for auto WPM
|
||||
if (cwWpmAutoCheck.checked) {
|
||||
onDurations.push(onDuration);
|
||||
if (onDurations.length > MAX_ON_DURATIONS) {
|
||||
onDurations.shift();
|
||||
}
|
||||
autoDetectWpm();
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
if (!cwWpmAutoCheck.checked) {
|
||||
wpm = parseInt(cwWpmInput.value, 10) || 15;
|
||||
}
|
||||
if (!cwToneAutoCheck.checked) {
|
||||
const newTone = parseInt(cwToneInput.value, 10) || 700;
|
||||
if (newTone !== toneFreq) {
|
||||
recomputeGoertzel(newTone);
|
||||
}
|
||||
}
|
||||
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);
|
||||
cwToneInput.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);
|
||||
document.getElementById("cw-clear-btn").addEventListener("click", () => {
|
||||
cwOutputEl.innerHTML = "";
|
||||
});
|
||||
@@ -461,6 +13,7 @@ document.getElementById("cw-clear-btn").addEventListener("click", () => {
|
||||
// --- Server-side CW decode handler ---
|
||||
let cwLastAppendTime = 0;
|
||||
window.onServerCw = function(evt) {
|
||||
cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text) {
|
||||
// Append decoded text to output
|
||||
const now = Date.now();
|
||||
@@ -483,14 +36,3 @@ window.onServerCw = function(evt) {
|
||||
cwWpmInput.value = evt.wpm;
|
||||
cwToneInput.value = evt.tone_hz;
|
||||
};
|
||||
|
||||
// Update status display based on server decode availability
|
||||
function updateCwStatus() {
|
||||
if (typeof decodeConnected !== "undefined" && decodeConnected) {
|
||||
if (!cwActive) {
|
||||
cwStatusEl.textContent = "Server decode active";
|
||||
cwToggleBtn.textContent = "Start CW (browser)";
|
||||
}
|
||||
}
|
||||
}
|
||||
setInterval(updateCwStatus, 2000);
|
||||
|
||||
Reference in New Issue
Block a user