From f2d0c92eb4867a8878af2f57205c3b495401da5c Mon Sep 17 00:00:00 2001 From: Tom M0LTE Date: Thu, 4 Jun 2026 04:00:57 +0000 Subject: [PATCH] sim-web/map: ScriptProcessorNode audio fallback for insecure (HTTP) contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map's "listen" feature opened an AudioWorklet to play a port's PCM. AudioWorklet is a secure-context-only API, so on plain HTTP served from a non-localhost host (e.g. http://packetdotnet:8090) `ctx.audioWorklet` is undefined and `ensureAudioCtx` threw `TypeError: Cannot read properties of undefined (reading 'addModule')` — audio never started. Branch the playback node in ensureAudioCtx: keep the AudioWorklet on secure contexts (HTTPS / localhost), and fall back to a ScriptProcessorNode when `ctx.audioWorklet` is absent. ScriptProcessorNode is deprecated and runs on the main thread, but it isn't secure-context-gated and is fine for monitoring one port. It uses the same queue/drain logic as the worklet (with a ~2 s backlog cap). The WebSocket onmessage now routes samples through a mode-agnostic `listenState.feed` so both paths share the receive code. No functional change on HTTPS/localhost (still uses the worklet). Unrelated to the 503s on /api/peer/chatbot/state — those are just the peer proxy reporting no `chatbot` container is resolvable on the Docker network; the map already hides that panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/sim-web/map.html | 58 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/cmd/sim-web/map.html b/cmd/sim-web/map.html index ee39f90..1d49bfd 100644 --- a/cmd/sim-web/map.html +++ b/cmd/sim-web/map.html @@ -1523,9 +1523,11 @@ // ============================================================ // // Click a port's ♪ icon → open WebSocket to /api/audio?port=&side=, -// receive raw int16 LE 44.1 kHz mono blocks, feed a single AudioWorklet -// node that drains them into the AudioContext at sample rate. Only -// one port at a time — clicking another switches over. +// receive raw int16 LE 44.1 kHz mono blocks, feed a single playback +// node that drains them into the AudioContext at sample rate. Secure +// contexts (HTTPS / localhost) use an AudioWorklet; plain HTTP falls +// back to a ScriptProcessorNode (see ensureAudioCtx). Only one port at +// a time — clicking another switches over. // Worklet code inlined and shipped as a Blob URL so we don't need a // second served file. It just queues incoming Float32 samples and @@ -1557,7 +1559,8 @@ const listenState = { ctx: null, // AudioContext (lazy, needs a user gesture) - node: null, // AudioWorkletNode + node: null, // AudioWorkletNode (secure ctx) or ScriptProcessorNode (fallback) + feed: null, // (Float32Array) => void — push samples to the active node ws: null, // WebSocket currentKey: null, // "|" port: null, @@ -1571,12 +1574,47 @@ const ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 44100, }); - const blob = new Blob([PCM_WORKLET], { type: 'application/javascript' }); - await ctx.audioWorklet.addModule(URL.createObjectURL(blob)); - const node = new AudioWorkletNode(ctx, 'pcm-source'); - node.connect(ctx.destination); + + if (ctx.audioWorklet) { + // Preferred path. AudioWorklet runs the queue/drain off the main + // thread, but it is a *secure-context* API — present only over + // HTTPS or on localhost. + const blob = new Blob([PCM_WORKLET], { type: 'application/javascript' }); + await ctx.audioWorklet.addModule(URL.createObjectURL(blob)); + const node = new AudioWorkletNode(ctx, 'pcm-source'); + node.connect(ctx.destination); + listenState.node = node; + listenState.feed = (f32) => node.port.postMessage(f32, [f32.buffer]); + } else { + // Fallback for insecure contexts (plain HTTP on a non-localhost + // host), where ctx.audioWorklet is undefined. ScriptProcessorNode + // is deprecated and runs on the main thread, but it isn't + // secure-context-gated and is perfectly fine for monitoring one + // port's audio. Same queue/drain logic as PCM_WORKLET, in JS. + const node = ctx.createScriptProcessor(2048, 0, 1); + let q = new Float32Array(0); + node.onaudioprocess = (e) => { + const out = e.outputBuffer.getChannelData(0); + const n = Math.min(q.length, out.length); + for (let i = 0; i < n; i++) out[i] = q[i]; + for (let i = n; i < out.length; i++) out[i] = 0; + q = q.subarray(n); + }; + node.connect(ctx.destination); + listenState.node = node; + listenState.feed = (f32) => { + // Cap the backlog (~2 s) so a backgrounded tab whose audio clock + // has paused can't grow this array without bound. + const MAX = 44100 * 2; + if (q.length > MAX) q = q.subarray(q.length - MAX); + const merged = new Float32Array(q.length + f32.length); + merged.set(q); + merged.set(f32, q.length); + q = merged; + }; + } + listenState.ctx = ctx; - listenState.node = node; return ctx; } @@ -1632,7 +1670,7 @@ const i16 = new Int16Array(e.data); const f32 = new Float32Array(i16.length); for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768; - listenState.node.port.postMessage(f32, [f32.buffer]); + listenState.feed(f32); }; ws.onerror = () => { $('listen-status').textContent = 'LINK ERR';