[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,69 +656,67 @@ 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 const sampleRate = streamInfo.sample_rate || 48000;
if (typeof AudioEncoder !== "undefined") { const channels = streamInfo.channels || 1;
const sampleRate = streamInfo.sample_rate || 48000; const encoder = new AudioEncoder({
const channels = streamInfo.channels || 1; output: (chunk) => {
const encoder = new AudioEncoder({ const buf = new ArrayBuffer(chunk.byteLength);
output: (chunk) => { chunk.copyTo(buf);
const buf = new ArrayBuffer(chunk.byteLength); if (audioWs && audioWs.readyState === WebSocket.OPEN) {
chunk.copyTo(buf); audioWs.send(buf);
if (audioWs && audioWs.readyState === WebSocket.OPEN) { }
audioWs.send(buf); },
} error: (e) => { console.error("AudioEncoder error", e); }
}, });
error: (e) => { console.error("AudioEncoder error", e); } encoder.configure({
}); codec: "opus",
encoder.configure({ sampleRate: sampleRate,
codec: "opus", numberOfChannels: channels,
sampleRate: sampleRate, bitrate: (streamInfo.bitrate_bps || 24000),
numberOfChannels: channels, });
bitrate: (streamInfo.bitrate_bps || 24000), window._txEncoder = encoder;
});
window._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 });
const source = audioCtx.createMediaStreamSource(stream); const source = audioCtx.createMediaStreamSource(stream);
const frameDuration = (streamInfo.frame_duration_ms || 20) / 1000; const frameDuration = (streamInfo.frame_duration_ms || 20) / 1000;
const frameSize = Math.floor(sampleRate * frameDuration); const frameSize = Math.floor(sampleRate * frameDuration);
// Use ScriptProcessorNode (deprecated but widely supported) // Use ScriptProcessorNode (deprecated but widely supported)
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 || !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 {
} const frame = new AudioData({
} format: "f32-planar",
try { sampleRate: input.sampleRate,
const frame = new AudioData({ numberOfFrames: input.length,
format: "f32-planar", numberOfChannels: 1,
sampleRate: input.sampleRate, timestamp: tsCounter,
numberOfFrames: input.length, data: monoData,
numberOfChannels: input.numberOfChannels, });
timestamp: tsCounter, tsCounter += (input.length / input.sampleRate) * 1_000_000;
data: input.getChannelData(0), window._txEncoder.encode(frame);
}); frame.close();
tsCounter += (input.length / input.sampleRate) * 1_000_000; } catch (e) {
window._txEncoder.encode(frame); // Ignore
frame.close(); }
} catch (e) { };
// Ignore source.connect(processor);
} processor.connect(audioCtx.destination);
}; txProcessor = { source, processor };
source.connect(processor);
processor.connect(audioCtx.destination);
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", "");
}
});