// --- 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 mark/space correlation detector (non-coherent FSK matched filter). 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; // 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 (1 bit period) const corrLen = Math.round(samplesPerBit); 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 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 ? "" : ' [CRC]'; let symbolHtml = ""; if (pkt.symbolTable && pkt.symbolCode) { const sheet = pkt.symbolTable === "/" ? 0 : 1; const code = pkt.symbolCode.charCodeAt(0) - 33; const col = code % 16; const row2 = Math.floor(code / 16); const bgX = -(col * 24); const bgY = -(row2 * 24); symbolHtml = ``; } let posHtml = ""; if (pkt.lat != null && pkt.lon != null) { const osmUrl = `https://www.openstreetmap.org/?mlat=${pkt.lat}&mlon=${pkt.lon}#map=15/${pkt.lat}/${pkt.lon}`; posHtml = ` ${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}`; } row.innerHTML = `${ts}${symbolHtml}${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}${posHtml}${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);