From 96edf7fc09afcec9e032a77b18d1e7b8a649fa73 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sat, 7 Feb 2026 15:00:10 +0100 Subject: [PATCH] [fix](trx-frontend-http): misc JS cleanup and UX polish Move window._opusDecoder, window._txEncoder, window._nextPlayTime into closure-scoped variables to avoid polluting the global namespace. Add showHint() helper to debounce status hint text, preventing multiple button handlers from fighting over powerHint.textContent. Throttle audio level indicator updates to max 10/sec instead of updating on every Opus packet (~50/sec). Hide audio controls row if the server has no audio configured (checks /audio endpoint on load). Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stanislaw Grams --- .../trx-frontend-http/assets/web/app.js | 134 ++++++++++-------- 1 file changed, 71 insertions(+), 63 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index a3c243c..fb79408 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -29,6 +29,13 @@ let lastControl; let lastTxEn = null; let lastRendered = null; let rigName = "Rig"; +let hintTimer = null; + +function showHint(msg, duration) { + powerHint.textContent = msg; + if (hintTimer) clearTimeout(hintTimer); + if (duration) hintTimer = setTimeout(() => { powerHint.textContent = "Ready"; }, duration); +} let supportedModes = []; let supportedBands = []; let freqDirty = false; @@ -313,14 +320,13 @@ async function postPath(path) { powerBtn.addEventListener("click", async () => { powerBtn.disabled = true; - powerHint.textContent = "Sending..."; + showHint("Sending..."); try { await postPath("/toggle_power"); - powerHint.textContent = "Toggled, waiting for update…"; + showHint("Toggled, waiting for update…"); } catch (err) { - powerHint.textContent = "Toggle failed"; + showHint("Toggle failed", 2000); console.error(err); - setTimeout(() => powerHint.textContent = "Ready", 2000); } finally { powerBtn.disabled = false; } @@ -328,19 +334,13 @@ powerBtn.addEventListener("click", async () => { vfoBtn.addEventListener("click", async () => { vfoBtn.disabled = true; - powerHint.textContent = "Toggling VFO…"; + showHint("Toggling VFO…"); try { await postPath("/toggle_vfo"); - powerHint.textContent = "VFO toggled, waiting for update…"; - setTimeout(() => { - if (powerHint.textContent.includes("VFO toggled")) { - powerHint.textContent = "Ready"; - } - }, 1200); + showHint("VFO toggled", 1200); } catch (err) { - powerHint.textContent = "VFO toggle failed"; + showHint("VFO toggle failed", 2000); console.error(err); - setTimeout(() => powerHint.textContent = "Ready", 2000); } finally { vfoBtn.disabled = false; } @@ -348,15 +348,14 @@ vfoBtn.addEventListener("click", async () => { pttBtn.addEventListener("click", async () => { pttBtn.disabled = true; - powerHint.textContent = "Toggling PTT…"; + showHint("Toggling PTT…"); try { const desired = lastTxEn ? "false" : "true"; await postPath(`/set_ptt?ptt=${desired}`); - powerHint.textContent = "PTT command sent"; + showHint("PTT command sent", 1500); } catch (err) { - powerHint.textContent = "PTT toggle failed"; + showHint("PTT toggle failed", 2000); console.error(err); - setTimeout(() => powerHint.textContent = "Ready", 2000); } finally { pttBtn.disabled = false; } @@ -365,24 +364,22 @@ pttBtn.addEventListener("click", async () => { freqBtn.addEventListener("click", async () => { const parsed = parseFreqInput(freqEl.value); if (parsed === null) { - powerHint.textContent = "Freq missing"; + showHint("Freq missing", 1500); return; } if (!freqAllowed(parsed)) { - powerHint.textContent = "Out of supported bands"; - setTimeout(() => powerHint.textContent = "Ready", 1500); + showHint("Out of supported bands", 1500); return; } freqDirty = false; freqBtn.disabled = true; - powerHint.textContent = "Setting frequency…"; + showHint("Setting frequency…"); try { await postPath(`/set_freq?hz=${parsed}`); - powerHint.textContent = "Freq set"; + showHint("Freq set", 1500); } catch (err) { - powerHint.textContent = "Set freq failed"; + showHint("Set freq failed", 2000); console.error(err); - setTimeout(() => powerHint.textContent = "Ready", 2000); } finally { freqBtn.disabled = false; } @@ -398,19 +395,18 @@ freqEl.addEventListener("keydown", (e) => { modeBtn.addEventListener("click", async () => { const mode = modeEl.value || ""; if (!mode) { - powerHint.textContent = "Mode missing"; + showHint("Mode missing", 1500); return; } modeDirty = false; modeBtn.disabled = true; - powerHint.textContent = "Setting mode…"; + showHint("Setting mode…"); try { await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`); - powerHint.textContent = "Mode set"; + showHint("Mode set", 1500); } catch (err) { - powerHint.textContent = "Set mode failed"; + showHint("Set mode failed", 2000); console.error(err); - setTimeout(() => powerHint.textContent = "Ready", 2000); } finally { modeBtn.disabled = false; } @@ -423,18 +419,17 @@ modeEl.addEventListener("input", () => { txLimitBtn.addEventListener("click", async () => { const limit = txLimitInput.value; if (limit === "" || limit === "--") { - powerHint.textContent = "Limit missing"; + showHint("Limit missing", 1500); return; } txLimitBtn.disabled = true; - powerHint.textContent = "Setting TX limit…"; + showHint("Setting TX limit…"); try { await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`); - powerHint.textContent = "TX limit set"; + showHint("TX limit set", 1500); } catch (err) { - powerHint.textContent = "TX limit failed"; + showHint("TX limit failed", 2000); console.error(err); - setTimeout(() => powerHint.textContent = "Ready", 2000); } finally { txLimitBtn.disabled = false; } @@ -442,15 +437,14 @@ txLimitBtn.addEventListener("click", async () => { lockBtn.addEventListener("click", async () => { lockBtn.disabled = true; - powerHint.textContent = "Toggling lock…"; + showHint("Toggling lock…"); try { const nextLock = lockBtn.textContent === "Lock"; await postPath(nextLock ? "/lock" : "/unlock"); - powerHint.textContent = "Lock toggled"; + showHint("Lock toggled", 1500); } catch (err) { - powerHint.textContent = "Lock toggle failed"; + showHint("Lock toggle failed", 2000); console.error(err); - setTimeout(() => powerHint.textContent = "Ready", 2000); } finally { lockBtn.disabled = false; } @@ -463,6 +457,12 @@ const rxAudioBtn = document.getElementById("rx-audio-btn"); const txAudioBtn = document.getElementById("tx-audio-btn"); const audioStatus = document.getElementById("audio-status"); const audioLevelFill = document.getElementById("audio-level-fill"); +const audioRow = document.getElementById("audio-row"); + +// Hide audio row if audio is not configured on the server +fetch("/audio", { method: "GET" }).then((r) => { + if (r.status === 404) audioRow.style.display = "none"; +}).catch(() => {}); let audioWs = null; let audioCtx = null; @@ -471,6 +471,10 @@ let txActive = false; let txStream = null; let txProcessor = null; let streamInfo = null; +let opusDecoder = null; +let txEncoder = null; +let nextPlayTime = 0; +let lastLevelUpdate = 0; const TX_TIMEOUT_SECS = 120; let txTimeoutTimer = null; let txTimeoutRemaining = 0; @@ -545,16 +549,20 @@ function startRxAudio() { if (!audioCtx) return; const data = new Uint8Array(evt.data); - // Show level indicator from packet size (rough estimate) - const level = Math.min(100, (data.length / 120) * 100); - audioLevelFill.style.width = `${level}%`; + // Throttle level indicator updates to max 10/sec + const now = Date.now(); + if (now - lastLevelUpdate >= 100) { + const level = Math.min(100, (data.length / 120) * 100); + audioLevelFill.style.width = `${level}%`; + lastLevelUpdate = now; + } // Use WebCodecs AudioDecoder for Opus if available - if (typeof AudioDecoder !== "undefined" && !window._opusDecoder) { + if (typeof AudioDecoder !== "undefined" && !opusDecoder) { try { const channels = (streamInfo && streamInfo.channels) || 1; const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000; - window._opusDecoder = new AudioDecoder({ + opusDecoder = new AudioDecoder({ output: (frame) => { const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels); frame.copyTo(buf, { planeIndex: 0 }); @@ -570,26 +578,26 @@ function startRxAudio() { src.buffer = ab; src.connect(audioCtx.destination); const now = audioCtx.currentTime; - const schedTime = Math.max(now, (window._nextPlayTime || now)); + const schedTime = Math.max(now, (nextPlayTime || now)); src.start(schedTime); - window._nextPlayTime = schedTime + ab.duration; + nextPlayTime = schedTime + ab.duration; frame.close(); }, error: (e) => { console.error("AudioDecoder error", e); } }); - window._opusDecoder.configure({ + opusDecoder.configure({ codec: "opus", sampleRate: sampleRate, numberOfChannels: channels, }); } catch (e) { console.warn("WebCodecs AudioDecoder not available for Opus", e); - window._opusDecoder = null; + opusDecoder = null; } } - if (window._opusDecoder) { + if (opusDecoder) { try { - window._opusDecoder.decode(new EncodedAudioChunk({ + opusDecoder.decode(new EncodedAudioChunk({ type: "key", timestamp: performance.now() * 1000, data: data, @@ -608,11 +616,11 @@ function startRxAudio() { rxAudioBtn.style.color = ""; audioStatus.textContent = "Off"; audioLevelFill.style.width = "0%"; - if (window._opusDecoder) { - try { window._opusDecoder.close(); } catch(e) {} - window._opusDecoder = null; + if (opusDecoder) { + try { opusDecoder.close(); } catch(e) {} + opusDecoder = null; } - window._nextPlayTime = 0; + nextPlayTime = 0; }; audioWs.onerror = () => { @@ -624,11 +632,11 @@ function stopRxAudio() { rxActive = false; if (audioWs) { audioWs.close(); audioWs = null; } if (audioCtx) { audioCtx.close(); audioCtx = null; } - if (window._opusDecoder) { - try { window._opusDecoder.close(); } catch(e) {} - window._opusDecoder = null; + if (opusDecoder) { + try { opusDecoder.close(); } catch(e) {} + opusDecoder = null; } - window._nextPlayTime = 0; + nextPlayTime = 0; rxAudioBtn.style.borderColor = ""; rxAudioBtn.style.color = ""; audioStatus.textContent = "Off"; @@ -681,7 +689,7 @@ function startTxAudio() { numberOfChannels: channels, bitrate: (streamInfo.bitrate_bps || 24000), }); - window._txEncoder = encoder; + txEncoder = encoder; // Use AudioWorklet or ScriptProcessor to feed encoder if (!audioCtx) audioCtx = new AudioContext({ sampleRate: sampleRate }); @@ -692,7 +700,7 @@ function startTxAudio() { const processor = audioCtx.createScriptProcessor(frameSize, channels, channels); let tsCounter = 0; processor.onaudioprocess = (e) => { - if (!txActive || !window._txEncoder) return; + if (!txActive || !txEncoder) return; const input = e.inputBuffer; // Reset PTT safety timeout on each audio callback resetTxTimeout(); @@ -708,7 +716,7 @@ function startTxAudio() { data: monoData, }); tsCounter += (input.length / input.sampleRate) * 1_000_000; - window._txEncoder.encode(frame); + txEncoder.encode(frame); frame.close(); } catch (e) { // Ignore @@ -740,9 +748,9 @@ async function stopTxAudio() { txProcessor.processor.disconnect(); txProcessor = null; } - if (window._txEncoder) { - try { window._txEncoder.close(); } catch(e) {} - window._txEncoder = null; + if (txEncoder) { + try { txEncoder.close(); } catch(e) {} + txEncoder = null; } txAudioBtn.style.borderColor = ""; txAudioBtn.style.color = "";