[feat](trx-frontend-http): add WASM Opus decoder fallback for Safari/iPadOS audio

WebCodecs AudioDecoder does not support Opus on Safari. Fall back to
opus-decoder WASM library (loaded from CDN) for browsers without
WebCodecs Opus support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-24 21:52:09 +01:00
parent 69fcc4b068
commit d88eb89e16
2 changed files with 120 additions and 68 deletions
@@ -7312,6 +7312,7 @@ let txStream = null;
let txProcessor = null; let txProcessor = null;
let streamInfo = null; let streamInfo = null;
let opusDecoder = null; let opusDecoder = null;
let wasmOpusDecoder = null;
let txEncoder = null; let txEncoder = null;
let nextPlayTime = 0; let nextPlayTime = 0;
let lastLevelUpdate = 0; let lastLevelUpdate = 0;
@@ -7324,6 +7325,7 @@ let txTimeoutTimer = null;
let txTimeoutRemaining = 0; let txTimeoutRemaining = 0;
let txTimeoutInterval = null; let txTimeoutInterval = null;
const hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined"; const hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined";
const hasWasmOpus = typeof window["opus-decoder"] !== "undefined" && typeof window["opus-decoder"].OpusDecoder !== "undefined";
const MAX_RX_BUFFER_SECS = 0.25; const MAX_RX_BUFFER_SECS = 0.25;
const TARGET_RX_BUFFER_SECS = 0.04; const TARGET_RX_BUFFER_SECS = 0.04;
const MIN_RX_JITTER_SAMPLES = 512; const MIN_RX_JITTER_SAMPLES = 512;
@@ -7601,6 +7603,10 @@ function resetRxDecoder() {
try { opusDecoder.close(); } catch (e) {} try { opusDecoder.close(); } catch (e) {}
opusDecoder = null; opusDecoder = null;
} }
if (wasmOpusDecoder) {
try { wasmOpusDecoder.free(); } catch (e) {}
wasmOpusDecoder = null;
}
nextPlayTime = 0; nextPlayTime = 0;
} }
@@ -7653,10 +7659,57 @@ function extractAudioFrameChannels(frame) {
// Optional channel_id injected by vchan.js when connecting to a virtual channel. // Optional channel_id injected by vchan.js when connecting to a virtual channel.
let _audioChannelOverride = null; let _audioChannelOverride = null;
/** Schedule decoded PCM channels for playback via Web Audio API. */
function scheduleDecodedAudio(channelData, frameCount, sampleRate) {
if (!audioCtx || !rxGainNode) return;
const levelNow = Date.now();
if (levelNow - lastLevelUpdate >= 50) {
setAudioLevel(levelFromChannels(channelData, frameCount));
lastLevelUpdate = levelNow;
}
const forceMono = channelData.length >= 2
&& wfmAudioModeEl
&& wfmAudioModeEl.value === "mono"
&& modeEl
&& (modeEl.value || "").toUpperCase() === "WFM";
const outChannels = forceMono ? 1 : channelData.length;
const ab = audioCtx.createBuffer(outChannels, frameCount, sampleRate);
if (forceMono) {
const monoData = new Float32Array(frameCount);
for (let ch = 0; ch < channelData.length; ch++) {
const plane = channelData[ch];
for (let i = 0; i < frameCount; i++) monoData[i] += plane[i];
}
const inv = 1 / Math.max(1, channelData.length);
for (let i = 0; i < frameCount; i++) monoData[i] *= inv;
ab.copyToChannel(monoData, 0);
} else {
for (let ch = 0; ch < channelData.length; ch++) {
ab.copyToChannel(channelData[ch], ch);
}
}
const src = audioCtx.createBufferSource();
src.buffer = ab;
src.connect(rxGainNode);
const now = audioCtx.currentTime;
const sr = (streamInfo && streamInfo.sample_rate) || sampleRate || 48000;
const minLeadSecs = Math.max(0, MIN_RX_JITTER_SAMPLES / Math.max(1, sr));
const targetLeadSecs = Math.max(TARGET_RX_BUFFER_SECS, minLeadSecs);
if (nextPlayTime && nextPlayTime - now > MAX_RX_BUFFER_SECS) {
nextPlayTime = now + targetLeadSecs;
}
if (!nextPlayTime || nextPlayTime < now + minLeadSecs) {
nextPlayTime = now + targetLeadSecs;
}
const schedTime = nextPlayTime || (now + targetLeadSecs);
src.start(schedTime);
nextPlayTime = schedTime + ab.duration;
}
function startRxAudio() { function startRxAudio() {
if (rxActive) { stopRxAudio(); return; } if (rxActive) { stopRxAudio(); return; }
if (!hasWebCodecs) { if (!hasWebCodecs && !hasWasmOpus) {
audioStatus.textContent = "Audio requires Chrome/Edge"; audioStatus.textContent = "Audio not supported in this browser";
return; return;
} }
ensureRxAudioContext((streamInfo && streamInfo.sample_rate) || 48000); ensureRxAudioContext((streamInfo && streamInfo.sample_rate) || 48000);
@@ -7691,74 +7744,59 @@ function startRxAudio() {
return; return;
} }
// Binary Opus data — decode via WebCodecs AudioDecoder if available // Binary Opus data
if (!audioCtx) return; if (!audioCtx) return;
const data = new Uint8Array(evt.data); const data = new Uint8Array(evt.data);
// Use WebCodecs AudioDecoder for Opus if available // Lazily initialise a decoder: prefer WebCodecs, fall back to WASM.
if (typeof AudioDecoder !== "undefined" && !opusDecoder) { if (!opusDecoder && !wasmOpusDecoder) {
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; // Try WebCodecs AudioDecoder first (Chrome/Edge).
opusDecoder = new AudioDecoder({ if (hasWebCodecs) {
output: (frame) => { try {
const frameChannels = extractAudioFrameChannels(frame); opusDecoder = new AudioDecoder({
const levelNow = Date.now(); output: (frame) => {
if (levelNow - lastLevelUpdate >= 50) { const ch = extractAudioFrameChannels(frame);
setAudioLevel(levelFromChannels(frameChannels, frame.numberOfFrames)); scheduleDecodedAudio(ch, frame.numberOfFrames, frame.sampleRate);
lastLevelUpdate = levelNow; frame.close();
} },
const forceMono = frame.numberOfChannels >= 2 error: (e) => { console.error("AudioDecoder error", e); }
&& wfmAudioModeEl });
&& wfmAudioModeEl.value === "mono" opusDecoder.configure({ codec: "opus", sampleRate, numberOfChannels: channels });
&& modeEl } catch (e) {
&& (modeEl.value || "").toUpperCase() === "WFM"; console.warn("WebCodecs Opus not supported, trying WASM fallback", e);
const outChannels = forceMono ? 1 : frameChannels.length; opusDecoder = null;
const ab = audioCtx.createBuffer(outChannels, frame.numberOfFrames, frame.sampleRate); }
if (forceMono) { }
const monoData = new Float32Array(frame.numberOfFrames); // WASM fallback (Safari/Firefox).
for (let ch = 0; ch < frameChannels.length; ch++) { if (!opusDecoder && hasWasmOpus) {
const plane = frameChannels[ch]; try {
for (let i = 0; i < frame.numberOfFrames; i++) monoData[i] += plane[i]; const coupledStreamCount = channels >= 2 ? 1 : 0;
} const mapping = channels >= 2 ? [0, 1] : [0];
const inv = 1 / Math.max(1, frameChannels.length); wasmOpusDecoder = new window["opus-decoder"].OpusDecoder({
for (let i = 0; i < frame.numberOfFrames; i++) monoData[i] *= inv; sampleRate,
ab.copyToChannel(monoData, 0); channels,
} else { streamCount: 1,
for (let ch = 0; ch < frameChannels.length; ch++) { coupledStreamCount,
ab.copyToChannel(frameChannels[ch], ch); channelMappingTable: mapping,
} preSkip: 0,
} });
const src = audioCtx.createBufferSource(); // .ready is a Promise that resolves when WASM is compiled.
src.buffer = ab; wasmOpusDecoder.ready.then(() => {
src.connect(rxGainNode); audioStatus.textContent = "RX";
const now = audioCtx.currentTime; }).catch((e) => {
const sampleRate = (streamInfo && streamInfo.sample_rate) || frame.sampleRate || 48000; console.error("WASM Opus init failed", e);
const minLeadSecs = Math.max(0, MIN_RX_JITTER_SAMPLES / Math.max(1, sampleRate)); wasmOpusDecoder = null;
const targetLeadSecs = Math.max(TARGET_RX_BUFFER_SECS, minLeadSecs); });
if (nextPlayTime && nextPlayTime - now > MAX_RX_BUFFER_SECS) { } catch (e) {
nextPlayTime = now + targetLeadSecs; console.warn("WASM Opus decoder init failed", e);
} wasmOpusDecoder = null;
if (!nextPlayTime || nextPlayTime < now + minLeadSecs) { }
nextPlayTime = now + targetLeadSecs;
}
const schedTime = nextPlayTime || (now + targetLeadSecs);
src.start(schedTime);
nextPlayTime = schedTime + ab.duration;
frame.close();
},
error: (e) => { console.error("AudioDecoder error", e); }
});
opusDecoder.configure({
codec: "opus",
sampleRate: sampleRate,
numberOfChannels: channels,
});
} catch (e) {
console.warn("WebCodecs AudioDecoder not available for Opus", e);
opusDecoder = null;
} }
} }
// Decode with whichever decoder is available.
if (opusDecoder) { if (opusDecoder) {
try { try {
opusDecoder.decode(new EncodedAudioChunk({ opusDecoder.decode(new EncodedAudioChunk({
@@ -7766,9 +7804,14 @@ function startRxAudio() {
timestamp: performance.now() * 1000, timestamp: performance.now() * 1000,
data: data, data: data,
})); }));
} catch (e) { } catch (e) { /* ignore per-frame errors */ }
// Ignore decode errors for individual frames } else if (wasmOpusDecoder) {
} try {
const result = wasmOpusDecoder.decodeFrame(data);
if (result && result.samplesDecoded > 0) {
scheduleDecodedAudio(result.channelData, result.samplesDecoded, result.sampleRate);
}
} catch (e) { /* ignore per-frame errors */ }
} }
}; };
@@ -7787,6 +7830,10 @@ function startRxAudio() {
try { opusDecoder.close(); } catch(e) {} try { opusDecoder.close(); } catch(e) {}
opusDecoder = null; opusDecoder = null;
} }
if (wasmOpusDecoder) {
try { wasmOpusDecoder.free(); } catch(e) {}
wasmOpusDecoder = null;
}
nextPlayTime = 0; nextPlayTime = 0;
syncHeaderAudioBtn(); syncHeaderAudioBtn();
}; };
@@ -7807,6 +7854,10 @@ function stopRxAudio() {
try { opusDecoder.close(); } catch(e) {} try { opusDecoder.close(); } catch(e) {}
opusDecoder = null; opusDecoder = null;
} }
if (wasmOpusDecoder) {
try { wasmOpusDecoder.free(); } catch(e) {}
wasmOpusDecoder = null;
}
nextPlayTime = 0; nextPlayTime = 0;
rxAudioBtn.style.borderColor = ""; rxAudioBtn.style.borderColor = "";
rxAudioBtn.style.color = ""; rxAudioBtn.style.color = "";
@@ -1046,6 +1046,7 @@
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div> <div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script>
<script src="/webgl-renderer.js"></script> <script src="/webgl-renderer.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
<script src="/ais.js"></script> <script src="/ais.js"></script>