[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 txProcessor = 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
let playBuffer = [];
let playNode = null;
// Show compatibility warning for non-Chromium browsers
if (!hasWebCodecs) {
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() {
if (rxActive) { stopRxAudio(); return; }
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
audioWs = new WebSocket(`${proto}//${location.host}/audio`);
audioWs.binaryType = "arraybuffer";
@@ -599,6 +637,10 @@ function stopRxAudio() {
function startTxAudio() {
if (txActive) { stopTxAudio(); return; }
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
}
if (!audioWs || audioWs.readyState !== WebSocket.OPEN) {
audioStatus.textContent = "RX first";
return;
@@ -614,69 +656,67 @@ function startTxAudio() {
txAudioBtn.style.color = "#e55353";
audioStatus.textContent = "RX+TX";
// Start PTT safety timeout
resetTxTimeout();
startTxTimeoutCountdown();
// Engage PTT automatically
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 channels = streamInfo.channels || 1;
const encoder = new AudioEncoder({
output: (chunk) => {
const buf = new ArrayBuffer(chunk.byteLength);
chunk.copyTo(buf);
if (audioWs && audioWs.readyState === WebSocket.OPEN) {
audioWs.send(buf);
}
},
error: (e) => { console.error("AudioEncoder error", e); }
});
encoder.configure({
codec: "opus",
sampleRate: sampleRate,
numberOfChannels: channels,
bitrate: (streamInfo.bitrate_bps || 24000),
});
window._txEncoder = encoder;
const sampleRate = streamInfo.sample_rate || 48000;
const channels = streamInfo.channels || 1;
const encoder = new AudioEncoder({
output: (chunk) => {
const buf = new ArrayBuffer(chunk.byteLength);
chunk.copyTo(buf);
if (audioWs && audioWs.readyState === WebSocket.OPEN) {
audioWs.send(buf);
}
},
error: (e) => { console.error("AudioEncoder error", e); }
});
encoder.configure({
codec: "opus",
sampleRate: sampleRate,
numberOfChannels: channels,
bitrate: (streamInfo.bitrate_bps || 24000),
});
window._txEncoder = encoder;
// Use AudioWorklet or ScriptProcessor to feed encoder
if (!audioCtx) audioCtx = new AudioContext({ sampleRate: sampleRate });
const source = audioCtx.createMediaStreamSource(stream);
const frameDuration = (streamInfo.frame_duration_ms || 20) / 1000;
const frameSize = Math.floor(sampleRate * frameDuration);
// Use ScriptProcessorNode (deprecated but widely supported)
const processor = audioCtx.createScriptProcessor(frameSize, channels, channels);
let tsCounter = 0;
processor.onaudioprocess = (e) => {
if (!txActive || !window._txEncoder) return;
const input = e.inputBuffer;
const data = new Float32Array(input.length * input.numberOfChannels);
for (let ch = 0; ch < input.numberOfChannels; ch++) {
const chData = input.getChannelData(ch);
for (let i = 0; i < input.length; i++) {
data[i * input.numberOfChannels + ch] = chData[i];
}
}
try {
const frame = new AudioData({
format: "f32-planar",
sampleRate: input.sampleRate,
numberOfFrames: input.length,
numberOfChannels: input.numberOfChannels,
timestamp: tsCounter,
data: input.getChannelData(0),
});
tsCounter += (input.length / input.sampleRate) * 1_000_000;
window._txEncoder.encode(frame);
frame.close();
} catch (e) {
// Ignore
}
};
source.connect(processor);
processor.connect(audioCtx.destination);
txProcessor = { source, processor };
}
// Use AudioWorklet or ScriptProcessor to feed encoder
if (!audioCtx) audioCtx = new AudioContext({ sampleRate: sampleRate });
const source = audioCtx.createMediaStreamSource(stream);
const frameDuration = (streamInfo.frame_duration_ms || 20) / 1000;
const frameSize = Math.floor(sampleRate * frameDuration);
// Use ScriptProcessorNode (deprecated but widely supported)
const processor = audioCtx.createScriptProcessor(frameSize, channels, channels);
let tsCounter = 0;
processor.onaudioprocess = (e) => {
if (!txActive || !window._txEncoder) return;
const input = e.inputBuffer;
// Reset PTT safety timeout on each audio callback
resetTxTimeout();
// Use mono (channel 0) for f32-planar format
const monoData = input.getChannelData(0);
try {
const frame = new AudioData({
format: "f32-planar",
sampleRate: input.sampleRate,
numberOfFrames: input.length,
numberOfChannels: 1,
timestamp: tsCounter,
data: monoData,
});
tsCounter += (input.length / input.sampleRate) * 1_000_000;
window._txEncoder.encode(frame);
frame.close();
} catch (e) {
// Ignore
}
};
source.connect(processor);
processor.connect(audioCtx.destination);
txProcessor = { source, processor };
}).catch((err) => {
console.error("getUserMedia failed:", err);
audioStatus.textContent = "Mic denied";
@@ -686,6 +726,7 @@ function startTxAudio() {
async function stopTxAudio() {
if (!txActive) return;
txActive = false;
clearTxTimeout();
// Release PTT automatically
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);
txAudioBtn.addEventListener("click", startTxAudio);
// Release PTT on page unload to prevent stuck transmit
window.addEventListener("beforeunload", () => {
if (txActive) {
navigator.sendBeacon("/set_ptt?ptt=false", "");
}
});