import { hostCore } from "./chunk-KL66PICH.js"; // src/plugins/cw.ts var cwWindow = window; var cwStatusEl = document.getElementById("cw-status"); var cwOutputEl = document.getElementById("cw-output"); var cwAutoInput = document.getElementById("cw-auto"); var cwWpmInput = document.getElementById("cw-wpm"); var cwToneInput = document.getElementById("cw-tone"); var cwSignalIndicator = document.getElementById("cw-signal-indicator"); var cwToneCanvas = document.getElementById("cw-tone-waterfall"); var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null; var cwTonePickerEl = document.querySelector(".cw-tone-picker"); var cwToneRangeEl = document.getElementById("cw-tone-range"); var cwBarOverlay = document.getElementById("cw-bar-overlay"); var CW_MAX_LINES = 200; var CW_TONE_MIN_HZ = 100; var CW_TONE_MAX_HZ = 1e4; var CW_WPM_MIN = 5; var CW_WPM_MAX = 40; var CW_BAR_WINDOW_MS = 15 * 60 * 1e3; var CW_BAR_LINE_GAP_MS = 5e3; var cwLastAppendTime = 0; var cwTonePickerRaf = null; var cwBarHistory = []; var cwBarCurrentLine = null; var cwBarDismissedAtMs = 0; var cwAutoLocalOverride = null; function escapeCwHtml(input) { return hostCore.escapeMapHtml(input); } function applyCwAutoUi(enabled) { if (cwAutoInput) cwAutoInput.checked = enabled; if (cwWpmInput) { cwWpmInput.disabled = enabled; cwWpmInput.readOnly = enabled; } if (cwToneInput) { cwToneInput.disabled = enabled; cwToneInput.readOnly = enabled; } if (cwTonePickerEl) { cwTonePickerEl.classList.toggle("is-auto", enabled); } } cwWindow.applyCwAutoUi = applyCwAutoUi; cwWindow.applyCwAutoUiFromServer = function(enabled) { if (cwAutoLocalOverride !== null) return; applyCwAutoUi(enabled); }; function cwBarFlushCurrentLine() { if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { cwBarHistory.unshift(cwBarCurrentLine); if (cwBarHistory.length > 50) cwBarHistory.length = 50; } cwBarCurrentLine = null; } function updateCwBar() { if (!cwBarOverlay) return; const mode = (document.getElementById("mode")?.value || "").toUpperCase(); const isCw = mode === "CW" || mode === "CWR"; const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0); if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { cwBarOverlay.style.display = "none"; cwBarOverlay.innerHTML = ""; return; } let html = `
`; for (const line of liveLines.slice(0, 8)) { const ts = line.ts ? `` : ""; const meta = [ line.wpm ? `${String(line.wpm)} WPM` : null, line.tone_hz ? `${String(line.tone_hz)} Hz` : null ].filter(Boolean).join(" · "); html += ``; } cwBarOverlay.innerHTML = html; cwBarOverlay.style.display = "flex"; } cwWindow.updateCwBar = updateCwBar; cwWindow.clearCwBar = function() { resetCwHistoryView(); }; cwWindow.closeCwBar = function() { cwBarDismissedAtMs = Date.now(); if (cwBarOverlay) { cwBarOverlay.style.display = "none"; cwBarOverlay.innerHTML = ""; } }; function clampCwWpm(wpm) { const numeric = Number(wpm); if (!Number.isFinite(numeric)) return 15; return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric))); } function clampCwTone(tone) { const numeric = Number(tone); if (!Number.isFinite(numeric)) return 700; return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric))); } function currentCwToneRange() { const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN; const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN; if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { return null; } const mode = (document.getElementById("mode")?.value || "").toUpperCase(); const lowerSideband = mode === "CWR"; const upperSideband = mode === "CW"; if (!lowerSideband && !upperSideband) return null; const toneMinHz = CW_TONE_MIN_HZ; const toneMaxHz = CW_TONE_MAX_HZ; return { tunedHz, bandwidthHz, toneMinHz, toneMaxHz, toneSpanHz: Math.max(1, toneMaxHz - toneMinHz), lowerSideband, mode }; } function cwToneToRfHz(range, toneHz) { if (!range) return NaN; return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz; } function toneClampForRange(tone, range) { const clamped = clampCwTone(tone); if (!range) return clamped; return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped)); } function ensureCwToneCanvasResolution() { if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false; const rect = cwToneCanvas.getBoundingClientRect(); const cssWidth = Math.round(rect.width); const cssHeight = Math.round(rect.height); if (cssWidth < 8 || cssHeight < 8) { return false; } const dpr = window.devicePixelRatio || 1; return cwToneGl.ensureSize(cssWidth, cssHeight, dpr); } function drawCwTonePicker() { if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return; ensureCwToneCanvasResolution(); if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return; const width = cwToneCanvas.width; const height = cwToneCanvas.height; cwToneGl.clear([0, 0, 0, 0]); const range = currentCwToneRange(); if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) { if (cwToneRangeEl) { const mode = (document.getElementById("mode")?.value || "").toUpperCase(); if (mode !== "CW" && mode !== "CWR") { cwToneRangeEl.textContent = "CW/CWR mode required"; } else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) { cwToneRangeEl.textContent = "Waiting for spectrum"; } } cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]); return; } if (cwToneRangeEl) { const side = range.lowerSideband ? "Lower side" : "Upper side"; cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`; } const bins = cwWindow.lastSpectrumData.bins; const sampleRate = cwWindow.lastSpectrumData.sample_rate; const centerHz = cwWindow.lastSpectrumData.center_hz; const maxIdx = Math.max(1, bins.length - 1); const fullLoHz = centerHz - sampleRate / 2; const tones = new Array(width).fill(-140); for (let x = 0; x < width; x += 1) { const frac = width <= 1 ? 0 : x / (width - 1); const toneHz = range.toneMinHz + frac * range.toneSpanHz; const rfHz = cwToneToRfHz(range, toneHz); const idx = Math.max(0, Math.min(maxIdx, Math.round((rfHz - fullLoHz) / sampleRate * maxIdx))); const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140; tones[x] = power; } const smoothed = new Array(width).fill(-140); const smoothRadius = Math.max(1, Math.round(width / 180)); for (let x = 0; x < width; x += 1) { let sum = 0; let count = 0; for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) { if (i < 0 || i >= width) continue; sum += tones[i] ?? -140; count += 1; } smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140; } const sorted = smoothed.slice().sort((a, b) => a - b); const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120; const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70; const floorDb = Math.min(q20 - 2, q95 - 10); const ceilDb = Math.max(floorDb + 18, q95 + 2); const dbSpan = Math.max(1, ceilDb - floorDb); const yForDb = (db) => { const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan)); return Math.round((1 - n) * (height - 1)); }; const rootStyle = getComputedStyle(document.documentElement); const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f"; const parseColor = typeof cwWindow.trxParseCssColor === "function" ? cwWindow.trxParseCssColor : null; const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1]; const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15]; cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]); const hGridCount = 4; const gridSegments = []; for (let i = 1; i <= hGridCount; i += 1) { const y = Math.round(i / (hGridCount + 1) * (height - 1)); gridSegments.push(0, y, width, y); } cwToneGl.drawSegments(gridSegments, axisColor, 1); const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200; const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep; const tickSegments = []; for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) { const frac = (tone - range.toneMinHz) / range.toneSpanHz; const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1)))); tickSegments.push(x, 0, x, height); } cwToneGl.drawSegments(tickSegments, axisColor, 1); const linePoints = []; for (let x = 0; x < width; x += 1) { linePoints.push(x, yForDb(smoothed[x] ?? -140)); } cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]); cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2)); const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range); const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz; const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1)))); const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140); cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5); cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]); if (cwAutoInput?.checked) { cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]); } } async function setCwTone(tone, { syncInput = true } = {}) { const range = currentCwToneRange(); const clamped = toneClampForRange(tone, range); if (cwToneInput && syncInput) { cwToneInput.value = String(clamped); } try { await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); } catch (e) { console.error("CW tone set failed", e); } drawCwTonePicker(); } if (cwAutoInput) { cwAutoInput.addEventListener("change", () => { void (async () => { const enabled = cwAutoInput.checked; cwAutoLocalOverride = enabled; applyCwAutoUi(enabled); try { await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); drawCwTonePicker(); } catch (error) { console.error("CW auto toggle failed", error); } finally { cwAutoLocalOverride = null; } })(); }); } if (cwWpmInput) { cwWpmInput.addEventListener("change", () => { void (async () => { if (cwAutoInput?.checked) return; const wpm = clampCwWpm(cwWpmInput.value); cwWpmInput.value = String(wpm); try { await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); } catch (error) { console.error("CW WPM set failed", error); } })(); }); } if (cwToneInput) { cwToneInput.addEventListener("change", () => { if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value); }); } if (cwToneCanvas) { cwToneCanvas.addEventListener("click", (event) => { if (cwAutoInput?.checked) return; const rect = cwToneCanvas.getBoundingClientRect(); if (rect.width <= 0) return; const range = currentCwToneRange(); if (!range) return; const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); const tone = range.toneMinHz + frac * range.toneSpanHz; void setCwTone(tone); }); } function resetCwHistoryView() { if (cwOutputEl) cwOutputEl.innerHTML = ""; cwLastAppendTime = 0; cwBarHistory = []; cwBarCurrentLine = null; updateCwBar(); drawCwTonePicker(); } document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => { void (async () => { if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; try { await hostCore.postPath("/clear_cw_decode"); resetCwHistoryView(); } catch (error) { console.error("CW history clear failed", error); } })(); }); function onServerCw(evt) { if (cwStatusEl) cwStatusEl.textContent = "Receiving"; if (evt.text && cwOutputEl) { const now = Date.now(); if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") { const line = document.createElement("div"); line.className = "cw-line"; cwOutputEl.appendChild(line); } cwLastAppendTime = now; const lastLine = cwOutputEl.lastElementChild; if (lastLine) { lastLine.textContent += evt.text; } while (cwOutputEl.children.length > CW_MAX_LINES) { const firstChild = cwOutputEl.firstChild; if (!firstChild) break; cwOutputEl.removeChild(firstChild); } cwOutputEl.scrollTop = cwOutputEl.scrollHeight; } if (evt.text) { const now = Date.now(); if (evt.text === "\n") { cwBarFlushCurrentLine(); } else { if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) { cwBarFlushCurrentLine(); const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; } cwBarCurrentLine.text += evt.text; cwBarCurrentLine.lastMs = now; if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm); if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz)); } updateCwBar(); } if (cwSignalIndicator) { cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; } if (!cwAutoInput || cwAutoInput.checked) { if (cwWpmInput && Number.isFinite(Number(evt.wpm))) { cwWpmInput.value = String(clampCwWpm(evt.wpm)); } if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) { cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange())); } } if (cwTonePickerRaf != null) return; cwTonePickerRaf = requestAnimationFrame(() => { cwTonePickerRaf = null; drawCwTonePicker(); }); } function restoreCwHistory(events) { if (!Array.isArray(events) || events.length === 0) return; if (cwStatusEl) cwStatusEl.textContent = "Receiving"; for (const evt of events) { onServerCw(evt); } } cwWindow.trxPluginRuntime.registerDecoder({ id: "cw", onMessage: onServerCw, restore: restoreCwHistory, reset: resetCwHistoryView }); cwWindow.refreshCwTonePicker = function refreshCwTonePicker() { ensureCwToneCanvasResolution(); drawCwTonePicker(); }; window.addEventListener("resize", () => { if (ensureCwToneCanvasResolution()) drawCwTonePicker(); }); applyCwAutoUi(!!cwAutoInput?.checked); updateCwBar(); ensureCwToneCanvasResolution(); drawCwTonePicker();