[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 streamInfo = null;
let opusDecoder = null;
let wasmOpusDecoder = null;
let txEncoder = null;
let nextPlayTime = 0;
let lastLevelUpdate = 0;
@@ -7324,6 +7325,7 @@ let txTimeoutTimer = null;
let txTimeoutRemaining = 0;
let txTimeoutInterval = null;
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 TARGET_RX_BUFFER_SECS = 0.04;
const MIN_RX_JITTER_SAMPLES = 512;
@@ -7601,6 +7603,10 @@ function resetRxDecoder() {
try { opusDecoder.close(); } catch (e) {}
opusDecoder = null;
}
if (wasmOpusDecoder) {
try { wasmOpusDecoder.free(); } catch (e) {}
wasmOpusDecoder = null;
}
nextPlayTime = 0;
}
@@ -7653,10 +7659,57 @@ function extractAudioFrameChannels(frame) {
// Optional channel_id injected by vchan.js when connecting to a virtual channel.
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() {
if (rxActive) { stopRxAudio(); return; }
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
if (!hasWebCodecs && !hasWasmOpus) {
audioStatus.textContent = "Audio not supported in this browser";
return;
}
ensureRxAudioContext((streamInfo && streamInfo.sample_rate) || 48000);
@@ -7691,74 +7744,59 @@ function startRxAudio() {
return;
}
// Binary Opus data — decode via WebCodecs AudioDecoder if available
// Binary Opus data
if (!audioCtx) return;
const data = new Uint8Array(evt.data);
// Use WebCodecs AudioDecoder for Opus if available
if (typeof AudioDecoder !== "undefined" && !opusDecoder) {
try {
// Lazily initialise a decoder: prefer WebCodecs, fall back to WASM.
if (!opusDecoder && !wasmOpusDecoder) {
const channels = (streamInfo && streamInfo.channels) || 1;
const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000;
// Try WebCodecs AudioDecoder first (Chrome/Edge).
if (hasWebCodecs) {
try {
opusDecoder = new AudioDecoder({
output: (frame) => {
const frameChannels = extractAudioFrameChannels(frame);
const levelNow = Date.now();
if (levelNow - lastLevelUpdate >= 50) {
setAudioLevel(levelFromChannels(frameChannels, frame.numberOfFrames));
lastLevelUpdate = levelNow;
}
const forceMono = frame.numberOfChannels >= 2
&& wfmAudioModeEl
&& wfmAudioModeEl.value === "mono"
&& modeEl
&& (modeEl.value || "").toUpperCase() === "WFM";
const outChannels = forceMono ? 1 : frameChannels.length;
const ab = audioCtx.createBuffer(outChannels, frame.numberOfFrames, frame.sampleRate);
if (forceMono) {
const monoData = new Float32Array(frame.numberOfFrames);
for (let ch = 0; ch < frameChannels.length; ch++) {
const plane = frameChannels[ch];
for (let i = 0; i < frame.numberOfFrames; i++) monoData[i] += plane[i];
}
const inv = 1 / Math.max(1, frameChannels.length);
for (let i = 0; i < frame.numberOfFrames; i++) monoData[i] *= inv;
ab.copyToChannel(monoData, 0);
} else {
for (let ch = 0; ch < frameChannels.length; ch++) {
ab.copyToChannel(frameChannels[ch], ch);
}
}
const src = audioCtx.createBufferSource();
src.buffer = ab;
src.connect(rxGainNode);
const now = audioCtx.currentTime;
const sampleRate = (streamInfo && streamInfo.sample_rate) || frame.sampleRate || 48000;
const minLeadSecs = Math.max(0, MIN_RX_JITTER_SAMPLES / Math.max(1, sampleRate));
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;
const ch = extractAudioFrameChannels(frame);
scheduleDecodedAudio(ch, frame.numberOfFrames, frame.sampleRate);
frame.close();
},
error: (e) => { console.error("AudioDecoder error", e); }
});
opusDecoder.configure({
codec: "opus",
sampleRate: sampleRate,
numberOfChannels: channels,
});
opusDecoder.configure({ codec: "opus", sampleRate, numberOfChannels: channels });
} catch (e) {
console.warn("WebCodecs AudioDecoder not available for Opus", e);
console.warn("WebCodecs Opus not supported, trying WASM fallback", e);
opusDecoder = null;
}
}
// WASM fallback (Safari/Firefox).
if (!opusDecoder && hasWasmOpus) {
try {
const coupledStreamCount = channels >= 2 ? 1 : 0;
const mapping = channels >= 2 ? [0, 1] : [0];
wasmOpusDecoder = new window["opus-decoder"].OpusDecoder({
sampleRate,
channels,
streamCount: 1,
coupledStreamCount,
channelMappingTable: mapping,
preSkip: 0,
});
// .ready is a Promise that resolves when WASM is compiled.
wasmOpusDecoder.ready.then(() => {
audioStatus.textContent = "RX";
}).catch((e) => {
console.error("WASM Opus init failed", e);
wasmOpusDecoder = null;
});
} catch (e) {
console.warn("WASM Opus decoder init failed", e);
wasmOpusDecoder = null;
}
}
}
// Decode with whichever decoder is available.
if (opusDecoder) {
try {
opusDecoder.decode(new EncodedAudioChunk({
@@ -7766,9 +7804,14 @@ function startRxAudio() {
timestamp: performance.now() * 1000,
data: data,
}));
} catch (e) {
// Ignore decode errors for individual frames
} catch (e) { /* ignore per-frame errors */ }
} 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) {}
opusDecoder = null;
}
if (wasmOpusDecoder) {
try { wasmOpusDecoder.free(); } catch(e) {}
wasmOpusDecoder = null;
}
nextPlayTime = 0;
syncHeaderAudioBtn();
};
@@ -7807,6 +7854,10 @@ function stopRxAudio() {
try { opusDecoder.close(); } catch(e) {}
opusDecoder = null;
}
if (wasmOpusDecoder) {
try { wasmOpusDecoder.free(); } catch(e) {}
wasmOpusDecoder = null;
}
nextPlayTime = 0;
rxAudioBtn.style.borderColor = "";
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>
</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="/app.js"></script>
<script src="/ais.js"></script>