[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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user