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';