--- a/runtime/audio.js 2026-10-13 10:36:31.489048874 +0000 +++ b/runtime/audio.js 2026-10-13 10:36:31.492051502 +0000 @@ -26,7 +26,7 @@ this.readPtr = 0; this.writePtr = 0; - this.scriptNode = this.audioContext.createScriptProcessor(0, 0, 2); + this.scriptNode = this.audioContext.createScriptProcessor(2048, 0, 2); this.scriptNode.onaudioprocess = (audioProcessingEvent) => { const outputBuffer = audioProcessingEvent.outputBuffer; const leftData = outputBuffer.getChannelData(0); @@ -34,6 +34,15 @@ let availableDataLength = this.writePtr - this.readPtr; if (availableDataLength < 0) availableDataLength += BUFFER_SIZE; + // jacar.es: never let the sound fall behind the picture. Samples pile + // up whenever frames are produced while the context is not playing + // them (started suspended, a stalled tab), and this loop only ever + // reads one buffer at a time, so the lag would stay for good: skip + // ahead to a small, fixed backlog instead. + if (availableDataLength > 2 * leftData.length + 2 * this.samplesPerFrame) { + this.readPtr = (this.writePtr - leftData.length - this.samplesPerFrame + BUFFER_SIZE) % BUFFER_SIZE; + availableDataLength = leftData.length + this.samplesPerFrame; + } if (availableDataLength >= leftData.length) { // enough data is available to fill the buffer --- a/runtime/jsspeccy.js 2026-10-13 10:36:31.489145458 +0000 +++ b/runtime/jsspeccy.js 2026-10-13 10:36:31.492103710 +0000 @@ -24,7 +24,7 @@ constructor(canvas, opts) { super(); this.canvas = canvas; - this.worker = new Worker(new URL('jsspeccy-worker.js', scriptUrl)); + this.worker = new Worker(new URL('jsspeccy-worker.js' + new URL(scriptUrl).search, scriptUrl)) /* jacar.es: the page's version query, so a new worker is never served from cache */; this.keyboardEnabled = ('keyboardEnabled' in opts) ? opts.keyboardEnabled : true; if (this.keyboardEnabled) { this.keyboardHandler = (opts.keyboardMap == 'recreated') @@ -392,6 +392,11 @@ message: 'stopTape', }); } + pokeMemory(pokes) { + this.worker.postMessage({ + message: 'poke', pokes, + }); + } exit() { this.pause(); @@ -727,5 +732,14 @@ } }, exit: () => {exit();}, + // jacar.es: the tape transport, so a page can drive it from its own + // cassette deck (and follow it: 'playingTape' / 'stoppedTape') + playTape: () => {emu.playTape();}, + stopTape: () => {emu.stopTape();}, + isTapePlaying: () => emu.tapeIsPlaying, + setAutoLoadTapes: (val) => {emu.setAutoLoadTapes(val);}, + reset: () => {emu.reset();}, + poke: (pokes) => {emu.pokeMemory(pokes);}, + on: (event, handler) => {emu.on(event, handler);}, }; }; --- a/runtime/worker.js 2026-10-13 10:36:31.489196249 +0000 +++ b/runtime/worker.js 2026-10-13 10:36:38.878030684 +0000 @@ -13,8 +13,13 @@ let tapeIsPlaying = false; const loadCore = (baseUrl) => { - WebAssembly.instantiateStreaming( - fetch(new URL('jsspeccy-core.wasm', baseUrl), {}) + // jacar.es: compile from the bytes, not the stream: instantiateStreaming + // insists on the application/wasm MIME type, and a server that sends .wasm + // as application/octet-stream left the emulator without its core. + fetch(new URL('jsspeccy-core.wasm', baseUrl)).then( + r => r.arrayBuffer() + ).then( + bytes => WebAssembly.instantiate(bytes, {}) ).then(results => { core = results.instance.exports; memory = core.memory; @@ -250,6 +255,10 @@ case 'setTapeTraps': core.setTapeTraps(e.data.value); break; + case 'poke': + // jacar.es: write bytes into the running machine's memory (POKEs) + for (const [addr, val] of e.data.pokes) core.poke(addr, val); + break; default: console.log('message received by worker:', e.data); }