diff --git a/eslint.config.mjs b/eslint.config.mjs index 10d5dfd..7eb8084 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,7 +35,12 @@ export default [ }, }, { - files: ["**/webpack.config.js", "**/vite.config.js", "**/test/setup.js", "**/vitest.config.mjs"], + files: [ + "**/webpack.config.js", + "**/vite.config.js", + "**/test/setup.js", + "**/vitest.config.mjs", + ], languageOptions: { globals: { diff --git a/jsbeeb-worklet-middleware.js b/jsbeeb-worklet-middleware.js new file mode 100644 index 0000000..a53e6bb --- /dev/null +++ b/jsbeeb-worklet-middleware.js @@ -0,0 +1,162 @@ +// jsbeeb-worklet-middleware.js +// A Vite plugin that provides middleware to handle jsbeeb worklet files +import {build} from "esbuild"; +import {resolve} from "path"; +import fs from "fs"; + +// Path to jsbeeb in node_modules +// Using '.' is more reliable than process.cwd() and avoids ESLint errors +const jsbeebPath = resolve(".", "node_modules/jsbeeb"); + +// Create Vite plugin +export default function jsbeebWorkletPlugin() { + console.log("Initializing jsbeeb worklet middleware plugin"); + + // Map of worklet endpoints to their source files (using Map for better performance) + const workletMap = new Map([ + ["audio-renderer.js", resolve(jsbeebPath, "src/web/audio-renderer.js")], + ["music5000-worklet.js", resolve(jsbeebPath, "src/music5000-worklet.js")], + ]); + + // Helper function for handling errors consistently + function handleError(res, message, error, statusCode = 500) { + console.error(`[jsbeeb-worklet-middleware] ${message}:`, error); + res.statusCode = statusCode; + res.end(`${message}: ${error.message}`); + } + + // Helper function to build and serve a worklet + async function serveWorklet(sourcePath, res) { + try { + // Ensure the source file exists + if (!fs.existsSync(sourcePath)) { + handleError( + res, + `Worklet source file not found: ${sourcePath}`, + {message: "File not found"}, + 404, + ); + return; + } + + // Build the worklet on demand + console.log(`[jsbeeb-worklet-middleware] Building worklet from: ${sourcePath}`); + + // Read the source file + const sourceCode = fs.readFileSync(sourcePath, "utf8"); + + // For audio worklets, we need to ensure they have the proper context + // The global 'sampleRate' and 'currentTime' variables are expected in worklets + const preamble = ` + // Worklet environment setup for development mode + `; + + const result = await build({ + stdin: { + contents: preamble + sourceCode, + loader: "js", + resolveDir: resolve(sourcePath, ".."), + }, + bundle: true, + write: false, + format: "iife", // Audio worklets use IIFE format + target: ["es2020"], + outfile: "out.js", + define: { + "process.env.NODE_ENV": '"development"', + }, + }); + + // Serve the compiled result + console.log(`[jsbeeb-worklet-middleware] Serving built worklet`); + res.setHeader("Content-Type", "application/javascript"); + res.end(result.outputFiles[0].text); + } catch (error) { + handleError(res, "Error building worklet", error); + } + } + + return { + name: "jsbeeb-worklet-middleware", + // Add transform hook to handle modules during the transform phase + transform(code, id) { + // Look for specific modules to transform + if (id.includes("jsbeeb/src/web/audio-handler.js")) { + console.log(`[transform] Transforming audio-handler.js for development mode`); + + // Replace the problematic imports and fix audio context for development mode + return code + .replace( + `const rendererUrl = new URL("./audio-renderer.js", import.meta.url).href;`, + `// Modified by worklet middleware for dev mode + const rendererUrl = "/jsbeeb-worklets/audio-renderer.js";`, + ) + .replace( + `const music5000WorkletUrl = new URL("../music5000-worklet.js", import.meta.url).href;`, + `// Modified by worklet middleware for dev mode + const music5000WorkletUrl = "/jsbeeb-worklets/music5000-worklet.js";`, + ); + } + + // Check if this is a worklet file that needs transformation + // Use the workletMap values to identify worklet source files + for (const [, sourcePath] of workletMap) { + if (id.includes(sourcePath)) { + console.log(`[transform] Transforming worklet file: ${id}`); + // No transformations needed for the worklet files themselves, just ensure they're processed + return code; + } + } + + // Add other transformation rules for different files if needed + return null; // Return null to let Vite handle the file normally + }, + + configureServer(server) { + console.log("[jsbeeb-worklet-middleware] Setting up middleware for worklets"); + + // Add the main middleware handler using recommended Vite approach + server.middlewares.use(async (req, res, next) => { + const url = req.url; + const parsedUrl = new URL(url, "http://localhost"); + const pathname = parsedUrl.pathname; + + // Log all relevant requests for debugging + if (pathname.includes("jsbeeb") || pathname.includes("worklet")) { + console.log(`[jsbeeb-worklet-middleware] Request: ${pathname}`); + } + + // Extract worklet name from URL path for various patterns + let workletName = null; + + // Check for dedicated endpoints first (most specific) + if (pathname.startsWith("/jsbeeb-worklets/")) { + workletName = pathname.substring("/jsbeeb-worklets/".length); + } + // Then check for any other pattern containing known worklet names + else { + for (const [key] of workletMap) { + if (pathname.includes(key)) { + workletName = key; + break; + } + } + } + + // If we found a worklet name, try to serve it + if (workletName && workletMap.has(workletName)) { + console.log(`[jsbeeb-worklet-middleware] Serving worklet: ${workletName}`); + try { + await serveWorklet(workletMap.get(workletName), res); + } catch (error) { + handleError(res, `Error serving worklet ${workletName}`, error); + } + return; + } + + // Otherwise continue with the next middleware + next(); + }); + }, + }; +} diff --git a/package-lock.json b/package-lock.json index 4f50c82..c5e065a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "base2048": "^2.0.2", "jquery": "^3.7.1", - "jsbeeb": "github:mattgodbolt/jsbeeb#275b99ddeaf096d2497f606a4ad0ce74991306e1", + "jsbeeb": "github:mattgodbolt/jsbeeb#77be55899c28223d964493c9ad8b60b1ffd37185", "monaco-editor": "^0.52.2", "promise": "^8.3.0", "resize-observer-polyfill": "^1.5.1", @@ -4026,8 +4026,8 @@ }, "node_modules/jsbeeb": { "version": "0.0.7", - "resolved": "git+ssh://git@github.com/mattgodbolt/jsbeeb.git#275b99ddeaf096d2497f606a4ad0ce74991306e1", - "integrity": "sha512-/uuR1mSdeH0FssBKNMHUdh3shd5YjKRuBsc0xzZzSopzIpUJwEwgRJAk1yTTbZOwz7/w7sL2+3Amx+3Otzf68g==", + "resolved": "git+ssh://git@github.com/mattgodbolt/jsbeeb.git#77be55899c28223d964493c9ad8b60b1ffd37185", + "integrity": "sha512-Y+KatevAkLPehlcHdZVUQ3gxhuVM4gzQ8akGAfOcCR8zon4BPjnaqI7bsOCKBH6XXr2TksW7njWq+nc22M1Zuw==", "license": "GPL-3.0-or-later", "dependencies": { "@popperjs/core": "^2.11.8", diff --git a/package.json b/package.json index 5e508f0..a69c30c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dependencies": { "base2048": "^2.0.2", "jquery": "^3.7.1", - "jsbeeb": "github:mattgodbolt/jsbeeb#275b99ddeaf096d2497f606a4ad0ce74991306e1", + "jsbeeb": "github:mattgodbolt/jsbeeb#77be55899c28223d964493c9ad8b60b1ffd37185", "monaco-editor": "^0.52.2", "promise": "^8.3.0", "resize-observer-polyfill": "^1.5.1", diff --git a/src/emulator.js b/src/emulator.js index 6b50f0c..78bc830 100644 --- a/src/emulator.js +++ b/src/emulator.js @@ -1,10 +1,10 @@ +import $ from "jquery"; import _ from "underscore"; import {Cpu6502} from "jsbeeb/src/6502"; import * as canvasLib from "jsbeeb/src/canvas"; import {Video} from "jsbeeb/src/video"; import {Debugger} from "jsbeeb/src/web/debug"; -import {FakeSoundChip} from "jsbeeb/src/soundchip"; -import {FakeDdNoise} from "jsbeeb/src/ddnoise"; +import {AudioHandler} from "jsbeeb/src/web/audio-handler"; import * as models from "jsbeeb/src/models"; import {Cmos} from "jsbeeb/src/cmos"; import * as utils from "jsbeeb/src/utils"; @@ -98,8 +98,20 @@ export class Emulator { this.video = new Video(Model.isMaster, this.canvas.fb32, _.bind(this.paint, this)); - this.soundChip = new FakeSoundChip(); - this.ddNoise = new FakeDdNoise(); + const audioFilterFreq = 7000; + const audioFilterQ = 5; + const noSeek = false; + this.audioHandler = new AudioHandler( + $("#audio-warning"), + $("#audio-stats")[0], + audioFilterFreq, + audioFilterQ, + noSeek, + ); + // Firefox will report that audio is suspended even when it will + // start playing without user interaction, so we need to delay a + // little to get a reliable indication. + window.setTimeout(() => this.audioHandler.checkStatus(), 1000); this.dbgr = new Debugger(this.video); const cmos = new Cmos({ @@ -114,14 +126,14 @@ export class Emulator { }, }); const config = { - keyLayout: 'natural', + keyLayout: "natural", }; this.cpu = new Emulator6502( Model, this.dbgr, this.video, - this.soundChip, - this.ddNoise, + this.audioHandler.soundChip, + this.audioHandler.ddNoise, cmos, config, ); @@ -140,7 +152,7 @@ export class Emulator { } async initialise() { - await Promise.all([this.cpu.initialise(), this.ddNoise.initialise()]); + await Promise.all([this.audioHandler.initialise(), this.cpu.initialise()]); this.ready = true; } @@ -163,11 +175,13 @@ export class Emulator { start() { if (this.running) return; + this.audioHandler.unmute(); this.running = true; requestAnimationFrame(this.onAnimFrame); } pause() { + this.audioHandler.mute(); this.running = false; } @@ -299,6 +313,7 @@ export class Emulator { keyDown(event) { if (!this.running) return; + this.audioHandler.tryResume(); const code = this.keyCode(event); const processor = this.cpu; @@ -323,6 +338,7 @@ export class Emulator { } mouseMove(event) { + this.audioHandler.tryResume(); this.showCoords = true; const processor = this.cpu; const screen = this.root.find(".screen"); diff --git a/src/owlet-editor.less b/src/owlet-editor.less index 17f808f..5c3c084 100644 --- a/src/owlet-editor.less +++ b/src/owlet-editor.less @@ -134,6 +134,13 @@ canvas.screen { border: none; } +#audio-stats { + bottom: 0; + right: 0; + position: fixed; + display: none; +} + #about { padding: 32px; display: none; diff --git a/src/owlet.js b/src/owlet.js index dab83ea..bd0e58e 100644 --- a/src/owlet.js +++ b/src/owlet.js @@ -189,10 +189,12 @@ export class OwletEditor { async setState(state) { // Turn invisible characters into equivalent visible ones. - // eslint-disable-next-line no-control-regex - const basic = state.program.replace(/[\x00-\x09\x0b-\x1f\x7f-\u009f]/g, function (c) { - return String.fromCharCode(c.charCodeAt(0) | 0x100); - }).replace(/[\u201c\u201d]/g, '"'); // BBCMicrobot normalises smartquotes like this. + const basic = state.program + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x09\x0b-\x1f\x7f-\u009f]/g, function (c) { + return String.fromCharCode(c.charCodeAt(0) | 0x100); + }) + .replace(/[\u201c\u201d]/g, '"'); // BBCMicrobot normalises smartquotes like this. this.editor.getModel().setValue(basic); this.selectView("screen"); diff --git a/src/root.html b/src/root.html index 9952d50..a0fff22 100644 --- a/src/root.html +++ b/src/root.html @@ -6,6 +6,9 @@
+
+ Your browser has suspended audio -- mouse click or key press for sound. +
@@ -54,6 +57,7 @@

+