[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 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -29,6 +29,13 @@ let lastControl;
|
|||||||
let lastTxEn = null;
|
let lastTxEn = null;
|
||||||
let lastRendered = null;
|
let lastRendered = null;
|
||||||
let rigName = "Rig";
|
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 supportedModes = [];
|
||||||
let supportedBands = [];
|
let supportedBands = [];
|
||||||
let freqDirty = false;
|
let freqDirty = false;
|
||||||
@@ -313,14 +320,13 @@ async function postPath(path) {
|
|||||||
|
|
||||||
powerBtn.addEventListener("click", async () => {
|
powerBtn.addEventListener("click", async () => {
|
||||||
powerBtn.disabled = true;
|
powerBtn.disabled = true;
|
||||||
powerHint.textContent = "Sending...";
|
showHint("Sending...");
|
||||||
try {
|
try {
|
||||||
await postPath("/toggle_power");
|
await postPath("/toggle_power");
|
||||||
powerHint.textContent = "Toggled, waiting for update…";
|
showHint("Toggled, waiting for update…");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
powerHint.textContent = "Toggle failed";
|
showHint("Toggle failed", 2000);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
|
||||||
} finally {
|
} finally {
|
||||||
powerBtn.disabled = false;
|
powerBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -328,19 +334,13 @@ powerBtn.addEventListener("click", async () => {
|
|||||||
|
|
||||||
vfoBtn.addEventListener("click", async () => {
|
vfoBtn.addEventListener("click", async () => {
|
||||||
vfoBtn.disabled = true;
|
vfoBtn.disabled = true;
|
||||||
powerHint.textContent = "Toggling VFO…";
|
showHint("Toggling VFO…");
|
||||||
try {
|
try {
|
||||||
await postPath("/toggle_vfo");
|
await postPath("/toggle_vfo");
|
||||||
powerHint.textContent = "VFO toggled, waiting for update…";
|
showHint("VFO toggled", 1200);
|
||||||
setTimeout(() => {
|
|
||||||
if (powerHint.textContent.includes("VFO toggled")) {
|
|
||||||
powerHint.textContent = "Ready";
|
|
||||||
}
|
|
||||||
}, 1200);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
powerHint.textContent = "VFO toggle failed";
|
showHint("VFO toggle failed", 2000);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
|
||||||
} finally {
|
} finally {
|
||||||
vfoBtn.disabled = false;
|
vfoBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -348,15 +348,14 @@ vfoBtn.addEventListener("click", async () => {
|
|||||||
|
|
||||||
pttBtn.addEventListener("click", async () => {
|
pttBtn.addEventListener("click", async () => {
|
||||||
pttBtn.disabled = true;
|
pttBtn.disabled = true;
|
||||||
powerHint.textContent = "Toggling PTT…";
|
showHint("Toggling PTT…");
|
||||||
try {
|
try {
|
||||||
const desired = lastTxEn ? "false" : "true";
|
const desired = lastTxEn ? "false" : "true";
|
||||||
await postPath(`/set_ptt?ptt=${desired}`);
|
await postPath(`/set_ptt?ptt=${desired}`);
|
||||||
powerHint.textContent = "PTT command sent";
|
showHint("PTT command sent", 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
powerHint.textContent = "PTT toggle failed";
|
showHint("PTT toggle failed", 2000);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
|
||||||
} finally {
|
} finally {
|
||||||
pttBtn.disabled = false;
|
pttBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -365,24 +364,22 @@ pttBtn.addEventListener("click", async () => {
|
|||||||
freqBtn.addEventListener("click", async () => {
|
freqBtn.addEventListener("click", async () => {
|
||||||
const parsed = parseFreqInput(freqEl.value);
|
const parsed = parseFreqInput(freqEl.value);
|
||||||
if (parsed === null) {
|
if (parsed === null) {
|
||||||
powerHint.textContent = "Freq missing";
|
showHint("Freq missing", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!freqAllowed(parsed)) {
|
if (!freqAllowed(parsed)) {
|
||||||
powerHint.textContent = "Out of supported bands";
|
showHint("Out of supported bands", 1500);
|
||||||
setTimeout(() => powerHint.textContent = "Ready", 1500);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
freqDirty = false;
|
freqDirty = false;
|
||||||
freqBtn.disabled = true;
|
freqBtn.disabled = true;
|
||||||
powerHint.textContent = "Setting frequency…";
|
showHint("Setting frequency…");
|
||||||
try {
|
try {
|
||||||
await postPath(`/set_freq?hz=${parsed}`);
|
await postPath(`/set_freq?hz=${parsed}`);
|
||||||
powerHint.textContent = "Freq set";
|
showHint("Freq set", 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
powerHint.textContent = "Set freq failed";
|
showHint("Set freq failed", 2000);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
|
||||||
} finally {
|
} finally {
|
||||||
freqBtn.disabled = false;
|
freqBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -398,19 +395,18 @@ freqEl.addEventListener("keydown", (e) => {
|
|||||||
modeBtn.addEventListener("click", async () => {
|
modeBtn.addEventListener("click", async () => {
|
||||||
const mode = modeEl.value || "";
|
const mode = modeEl.value || "";
|
||||||
if (!mode) {
|
if (!mode) {
|
||||||
powerHint.textContent = "Mode missing";
|
showHint("Mode missing", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
modeDirty = false;
|
modeDirty = false;
|
||||||
modeBtn.disabled = true;
|
modeBtn.disabled = true;
|
||||||
powerHint.textContent = "Setting mode…";
|
showHint("Setting mode…");
|
||||||
try {
|
try {
|
||||||
await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`);
|
await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`);
|
||||||
powerHint.textContent = "Mode set";
|
showHint("Mode set", 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
powerHint.textContent = "Set mode failed";
|
showHint("Set mode failed", 2000);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
|
||||||
} finally {
|
} finally {
|
||||||
modeBtn.disabled = false;
|
modeBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -423,18 +419,17 @@ modeEl.addEventListener("input", () => {
|
|||||||
txLimitBtn.addEventListener("click", async () => {
|
txLimitBtn.addEventListener("click", async () => {
|
||||||
const limit = txLimitInput.value;
|
const limit = txLimitInput.value;
|
||||||
if (limit === "" || limit === "--") {
|
if (limit === "" || limit === "--") {
|
||||||
powerHint.textContent = "Limit missing";
|
showHint("Limit missing", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
txLimitBtn.disabled = true;
|
txLimitBtn.disabled = true;
|
||||||
powerHint.textContent = "Setting TX limit…";
|
showHint("Setting TX limit…");
|
||||||
try {
|
try {
|
||||||
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
|
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
|
||||||
powerHint.textContent = "TX limit set";
|
showHint("TX limit set", 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
powerHint.textContent = "TX limit failed";
|
showHint("TX limit failed", 2000);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
|
||||||
} finally {
|
} finally {
|
||||||
txLimitBtn.disabled = false;
|
txLimitBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -442,15 +437,14 @@ txLimitBtn.addEventListener("click", async () => {
|
|||||||
|
|
||||||
lockBtn.addEventListener("click", async () => {
|
lockBtn.addEventListener("click", async () => {
|
||||||
lockBtn.disabled = true;
|
lockBtn.disabled = true;
|
||||||
powerHint.textContent = "Toggling lock…";
|
showHint("Toggling lock…");
|
||||||
try {
|
try {
|
||||||
const nextLock = lockBtn.textContent === "Lock";
|
const nextLock = lockBtn.textContent === "Lock";
|
||||||
await postPath(nextLock ? "/lock" : "/unlock");
|
await postPath(nextLock ? "/lock" : "/unlock");
|
||||||
powerHint.textContent = "Lock toggled";
|
showHint("Lock toggled", 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
powerHint.textContent = "Lock toggle failed";
|
showHint("Lock toggle failed", 2000);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
|
||||||
} finally {
|
} finally {
|
||||||
lockBtn.disabled = false;
|
lockBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -463,6 +457,12 @@ const rxAudioBtn = document.getElementById("rx-audio-btn");
|
|||||||
const txAudioBtn = document.getElementById("tx-audio-btn");
|
const txAudioBtn = document.getElementById("tx-audio-btn");
|
||||||
const audioStatus = document.getElementById("audio-status");
|
const audioStatus = document.getElementById("audio-status");
|
||||||
const audioLevelFill = document.getElementById("audio-level-fill");
|
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 audioWs = null;
|
||||||
let audioCtx = null;
|
let audioCtx = null;
|
||||||
@@ -471,6 +471,10 @@ let txActive = false;
|
|||||||
let txStream = null;
|
let txStream = null;
|
||||||
let txProcessor = null;
|
let txProcessor = null;
|
||||||
let streamInfo = null;
|
let streamInfo = null;
|
||||||
|
let opusDecoder = null;
|
||||||
|
let txEncoder = null;
|
||||||
|
let nextPlayTime = 0;
|
||||||
|
let lastLevelUpdate = 0;
|
||||||
const TX_TIMEOUT_SECS = 120;
|
const TX_TIMEOUT_SECS = 120;
|
||||||
let txTimeoutTimer = null;
|
let txTimeoutTimer = null;
|
||||||
let txTimeoutRemaining = 0;
|
let txTimeoutRemaining = 0;
|
||||||
@@ -545,16 +549,20 @@ function startRxAudio() {
|
|||||||
if (!audioCtx) return;
|
if (!audioCtx) return;
|
||||||
const data = new Uint8Array(evt.data);
|
const data = new Uint8Array(evt.data);
|
||||||
|
|
||||||
// Show level indicator from packet size (rough estimate)
|
// Throttle level indicator updates to max 10/sec
|
||||||
const level = Math.min(100, (data.length / 120) * 100);
|
const now = Date.now();
|
||||||
audioLevelFill.style.width = `${level}%`;
|
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
|
// Use WebCodecs AudioDecoder for Opus if available
|
||||||
if (typeof AudioDecoder !== "undefined" && !window._opusDecoder) {
|
if (typeof AudioDecoder !== "undefined" && !opusDecoder) {
|
||||||
try {
|
try {
|
||||||
const channels = (streamInfo && streamInfo.channels) || 1;
|
const channels = (streamInfo && streamInfo.channels) || 1;
|
||||||
const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000;
|
const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000;
|
||||||
window._opusDecoder = new AudioDecoder({
|
opusDecoder = new AudioDecoder({
|
||||||
output: (frame) => {
|
output: (frame) => {
|
||||||
const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels);
|
const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels);
|
||||||
frame.copyTo(buf, { planeIndex: 0 });
|
frame.copyTo(buf, { planeIndex: 0 });
|
||||||
@@ -570,26 +578,26 @@ function startRxAudio() {
|
|||||||
src.buffer = ab;
|
src.buffer = ab;
|
||||||
src.connect(audioCtx.destination);
|
src.connect(audioCtx.destination);
|
||||||
const now = audioCtx.currentTime;
|
const now = audioCtx.currentTime;
|
||||||
const schedTime = Math.max(now, (window._nextPlayTime || now));
|
const schedTime = Math.max(now, (nextPlayTime || now));
|
||||||
src.start(schedTime);
|
src.start(schedTime);
|
||||||
window._nextPlayTime = schedTime + ab.duration;
|
nextPlayTime = schedTime + ab.duration;
|
||||||
frame.close();
|
frame.close();
|
||||||
},
|
},
|
||||||
error: (e) => { console.error("AudioDecoder error", e); }
|
error: (e) => { console.error("AudioDecoder error", e); }
|
||||||
});
|
});
|
||||||
window._opusDecoder.configure({
|
opusDecoder.configure({
|
||||||
codec: "opus",
|
codec: "opus",
|
||||||
sampleRate: sampleRate,
|
sampleRate: sampleRate,
|
||||||
numberOfChannels: channels,
|
numberOfChannels: channels,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("WebCodecs AudioDecoder not available for Opus", e);
|
console.warn("WebCodecs AudioDecoder not available for Opus", e);
|
||||||
window._opusDecoder = null;
|
opusDecoder = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (window._opusDecoder) {
|
if (opusDecoder) {
|
||||||
try {
|
try {
|
||||||
window._opusDecoder.decode(new EncodedAudioChunk({
|
opusDecoder.decode(new EncodedAudioChunk({
|
||||||
type: "key",
|
type: "key",
|
||||||
timestamp: performance.now() * 1000,
|
timestamp: performance.now() * 1000,
|
||||||
data: data,
|
data: data,
|
||||||
@@ -608,11 +616,11 @@ function startRxAudio() {
|
|||||||
rxAudioBtn.style.color = "";
|
rxAudioBtn.style.color = "";
|
||||||
audioStatus.textContent = "Off";
|
audioStatus.textContent = "Off";
|
||||||
audioLevelFill.style.width = "0%";
|
audioLevelFill.style.width = "0%";
|
||||||
if (window._opusDecoder) {
|
if (opusDecoder) {
|
||||||
try { window._opusDecoder.close(); } catch(e) {}
|
try { opusDecoder.close(); } catch(e) {}
|
||||||
window._opusDecoder = null;
|
opusDecoder = null;
|
||||||
}
|
}
|
||||||
window._nextPlayTime = 0;
|
nextPlayTime = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
audioWs.onerror = () => {
|
audioWs.onerror = () => {
|
||||||
@@ -624,11 +632,11 @@ function stopRxAudio() {
|
|||||||
rxActive = false;
|
rxActive = false;
|
||||||
if (audioWs) { audioWs.close(); audioWs = null; }
|
if (audioWs) { audioWs.close(); audioWs = null; }
|
||||||
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||||||
if (window._opusDecoder) {
|
if (opusDecoder) {
|
||||||
try { window._opusDecoder.close(); } catch(e) {}
|
try { opusDecoder.close(); } catch(e) {}
|
||||||
window._opusDecoder = null;
|
opusDecoder = null;
|
||||||
}
|
}
|
||||||
window._nextPlayTime = 0;
|
nextPlayTime = 0;
|
||||||
rxAudioBtn.style.borderColor = "";
|
rxAudioBtn.style.borderColor = "";
|
||||||
rxAudioBtn.style.color = "";
|
rxAudioBtn.style.color = "";
|
||||||
audioStatus.textContent = "Off";
|
audioStatus.textContent = "Off";
|
||||||
@@ -681,7 +689,7 @@ function startTxAudio() {
|
|||||||
numberOfChannels: channels,
|
numberOfChannels: channels,
|
||||||
bitrate: (streamInfo.bitrate_bps || 24000),
|
bitrate: (streamInfo.bitrate_bps || 24000),
|
||||||
});
|
});
|
||||||
window._txEncoder = encoder;
|
txEncoder = encoder;
|
||||||
|
|
||||||
// Use AudioWorklet or ScriptProcessor to feed encoder
|
// Use AudioWorklet or ScriptProcessor to feed encoder
|
||||||
if (!audioCtx) audioCtx = new AudioContext({ sampleRate: sampleRate });
|
if (!audioCtx) audioCtx = new AudioContext({ sampleRate: sampleRate });
|
||||||
@@ -692,7 +700,7 @@ function startTxAudio() {
|
|||||||
const processor = audioCtx.createScriptProcessor(frameSize, channels, channels);
|
const processor = audioCtx.createScriptProcessor(frameSize, channels, channels);
|
||||||
let tsCounter = 0;
|
let tsCounter = 0;
|
||||||
processor.onaudioprocess = (e) => {
|
processor.onaudioprocess = (e) => {
|
||||||
if (!txActive || !window._txEncoder) return;
|
if (!txActive || !txEncoder) return;
|
||||||
const input = e.inputBuffer;
|
const input = e.inputBuffer;
|
||||||
// Reset PTT safety timeout on each audio callback
|
// Reset PTT safety timeout on each audio callback
|
||||||
resetTxTimeout();
|
resetTxTimeout();
|
||||||
@@ -708,7 +716,7 @@ function startTxAudio() {
|
|||||||
data: monoData,
|
data: monoData,
|
||||||
});
|
});
|
||||||
tsCounter += (input.length / input.sampleRate) * 1_000_000;
|
tsCounter += (input.length / input.sampleRate) * 1_000_000;
|
||||||
window._txEncoder.encode(frame);
|
txEncoder.encode(frame);
|
||||||
frame.close();
|
frame.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore
|
// Ignore
|
||||||
@@ -740,9 +748,9 @@ async function stopTxAudio() {
|
|||||||
txProcessor.processor.disconnect();
|
txProcessor.processor.disconnect();
|
||||||
txProcessor = null;
|
txProcessor = null;
|
||||||
}
|
}
|
||||||
if (window._txEncoder) {
|
if (txEncoder) {
|
||||||
try { window._txEncoder.close(); } catch(e) {}
|
try { txEncoder.close(); } catch(e) {}
|
||||||
window._txEncoder = null;
|
txEncoder = null;
|
||||||
}
|
}
|
||||||
txAudioBtn.style.borderColor = "";
|
txAudioBtn.style.borderColor = "";
|
||||||
txAudioBtn.style.color = "";
|
txAudioBtn.style.color = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user