[fix](trx-frontend-http): add PTT safety timeout and audio compatibility fallback

Add a 120-second TX safety timeout that auto-releases PTT if no mic
data flows (browser crash, disconnect). Timer resets on each audio
callback. Shows countdown in status when < 10s remaining.

Add beforeunload handler that releases PTT via navigator.sendBeacon
when the browser tab is closed during TX.

Detect WebCodecs support on page load and show "Audio requires
Chrome/Edge" on non-Chromium browsers instead of silently failing.

Remove dead playBuffer/playNode variables that were declared but
never used. Fix TX AudioData to always use mono (channel 0) with
numberOfChannels: 1 matching the f32-planar format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-07 14:52:51 +01:00
parent 83342628fa
commit 57d5b0633c
@@ -471,13 +471,51 @@ let txActive = false;
let txStream = null; let txStream = null;
let txProcessor = null; let txProcessor = null;
let streamInfo = null; let streamInfo = null;
const TX_TIMEOUT_SECS = 120;
let txTimeoutTimer = null;
let txTimeoutRemaining = 0;
let txTimeoutInterval = null;
const hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined";
// Simple ring-buffer based audio player // Show compatibility warning for non-Chromium browsers
let playBuffer = []; if (!hasWebCodecs) {
let playNode = null; rxAudioBtn.disabled = true;
txAudioBtn.disabled = true;
audioStatus.textContent = "Audio requires Chrome/Edge";
}
function resetTxTimeout() {
txTimeoutRemaining = TX_TIMEOUT_SECS;
if (txTimeoutTimer) clearTimeout(txTimeoutTimer);
txTimeoutTimer = setTimeout(() => {
console.warn("PTT safety timeout — stopping TX");
stopTxAudio();
}, TX_TIMEOUT_SECS * 1000);
}
function startTxTimeoutCountdown() {
txTimeoutRemaining = TX_TIMEOUT_SECS;
if (txTimeoutInterval) clearInterval(txTimeoutInterval);
txTimeoutInterval = setInterval(() => {
txTimeoutRemaining--;
if (txTimeoutRemaining <= 10 && txTimeoutRemaining > 0 && txActive) {
audioStatus.textContent = `TX timeout ${txTimeoutRemaining}s`;
}
}, 1000);
}
function clearTxTimeout() {
if (txTimeoutTimer) { clearTimeout(txTimeoutTimer); txTimeoutTimer = null; }
if (txTimeoutInterval) { clearInterval(txTimeoutInterval); txTimeoutInterval = null; }
txTimeoutRemaining = 0;
}
function startRxAudio() { function startRxAudio() {
if (rxActive) { stopRxAudio(); return; } if (rxActive) { stopRxAudio(); return; }
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
}
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
audioWs = new WebSocket(`${proto}//${location.host}/audio`); audioWs = new WebSocket(`${proto}//${location.host}/audio`);
audioWs.binaryType = "arraybuffer"; audioWs.binaryType = "arraybuffer";
@@ -599,6 +637,10 @@ function stopRxAudio() {
function startTxAudio() { function startTxAudio() {
if (txActive) { stopTxAudio(); return; } if (txActive) { stopTxAudio(); return; }
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
}
if (!audioWs || audioWs.readyState !== WebSocket.OPEN) { if (!audioWs || audioWs.readyState !== WebSocket.OPEN) {
audioStatus.textContent = "RX first"; audioStatus.textContent = "RX first";
return; return;
@@ -614,11 +656,13 @@ function startTxAudio() {
txAudioBtn.style.color = "#e55353"; txAudioBtn.style.color = "#e55353";
audioStatus.textContent = "RX+TX"; audioStatus.textContent = "RX+TX";
// Start PTT safety timeout
resetTxTimeout();
startTxTimeoutCountdown();
// Engage PTT automatically // Engage PTT automatically
try { await postPath("/set_ptt?ptt=true"); } catch (e) { console.error("PTT on failed", e); } try { await postPath("/set_ptt?ptt=true"); } catch (e) { console.error("PTT on failed", e); }
// If WebCodecs AudioEncoder is available, use it for Opus encoding
if (typeof AudioEncoder !== "undefined") {
const sampleRate = streamInfo.sample_rate || 48000; const sampleRate = streamInfo.sample_rate || 48000;
const channels = streamInfo.channels || 1; const channels = streamInfo.channels || 1;
const encoder = new AudioEncoder({ const encoder = new AudioEncoder({
@@ -650,21 +694,18 @@ function startTxAudio() {
processor.onaudioprocess = (e) => { processor.onaudioprocess = (e) => {
if (!txActive || !window._txEncoder) return; if (!txActive || !window._txEncoder) return;
const input = e.inputBuffer; const input = e.inputBuffer;
const data = new Float32Array(input.length * input.numberOfChannels); // Reset PTT safety timeout on each audio callback
for (let ch = 0; ch < input.numberOfChannels; ch++) { resetTxTimeout();
const chData = input.getChannelData(ch); // Use mono (channel 0) for f32-planar format
for (let i = 0; i < input.length; i++) { const monoData = input.getChannelData(0);
data[i * input.numberOfChannels + ch] = chData[i];
}
}
try { try {
const frame = new AudioData({ const frame = new AudioData({
format: "f32-planar", format: "f32-planar",
sampleRate: input.sampleRate, sampleRate: input.sampleRate,
numberOfFrames: input.length, numberOfFrames: input.length,
numberOfChannels: input.numberOfChannels, numberOfChannels: 1,
timestamp: tsCounter, timestamp: tsCounter,
data: input.getChannelData(0), data: monoData,
}); });
tsCounter += (input.length / input.sampleRate) * 1_000_000; tsCounter += (input.length / input.sampleRate) * 1_000_000;
window._txEncoder.encode(frame); window._txEncoder.encode(frame);
@@ -676,7 +717,6 @@ function startTxAudio() {
source.connect(processor); source.connect(processor);
processor.connect(audioCtx.destination); processor.connect(audioCtx.destination);
txProcessor = { source, processor }; txProcessor = { source, processor };
}
}).catch((err) => { }).catch((err) => {
console.error("getUserMedia failed:", err); console.error("getUserMedia failed:", err);
audioStatus.textContent = "Mic denied"; audioStatus.textContent = "Mic denied";
@@ -686,6 +726,7 @@ function startTxAudio() {
async function stopTxAudio() { async function stopTxAudio() {
if (!txActive) return; if (!txActive) return;
txActive = false; txActive = false;
clearTxTimeout();
// Release PTT automatically // Release PTT automatically
try { await postPath("/set_ptt?ptt=false"); } catch (e) { console.error("PTT off failed", e); } try { await postPath("/set_ptt?ptt=false"); } catch (e) { console.error("PTT off failed", e); }
@@ -710,3 +751,10 @@ async function stopTxAudio() {
rxAudioBtn.addEventListener("click", startRxAudio); rxAudioBtn.addEventListener("click", startRxAudio);
txAudioBtn.addEventListener("click", startTxAudio); txAudioBtn.addEventListener("click", startTxAudio);
// Release PTT on page unload to prevent stuck transmit
window.addEventListener("beforeunload", () => {
if (txActive) {
navigator.sendBeacon("/set_ptt?ptt=false", "");
}
});