diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07165c1..1ab3dd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: - uses: mymindstorm/setup-emsdk@v14 with: - version: 3.1.61 + version: 4.0.22 - name: Install npm dependencies working-directory: packages/librosa-wasm diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 456472c..1e7f1fb 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -27,7 +27,7 @@ jobs: - uses: mymindstorm/setup-emsdk@v14 with: - version: 3.1.61 + version: 4.0.22 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/web-pages.yml b/.github/workflows/web-pages.yml index 1318a34..437a224 100644 --- a/.github/workflows/web-pages.yml +++ b/.github/workflows/web-pages.yml @@ -39,7 +39,7 @@ jobs: - uses: mymindstorm/setup-emsdk@v14 with: - version: 3.1.61 + version: 4.0.22 - name: Install WASM package dependencies working-directory: packages/librosa-wasm diff --git a/packages/librosa-wasm/README.md b/packages/librosa-wasm/README.md index 4a11f06..abe19c7 100644 --- a/packages/librosa-wasm/README.md +++ b/packages/librosa-wasm/README.md @@ -10,6 +10,16 @@ const y = librosa.tone(440, { sr: 22050, duration: 1.0 }); const mfcc = librosa.mfcc(y, { sr: 22050, nMfcc: 13 }); ``` +The package ships both an ES module and a CommonJS build, so it works directly +from `import` (ESM, bundlers, browsers) and from `require` (Electron main, plain +Node, Ableton Extension Host) with no extra conversion: + +```js +const { createLibrosa } = require("@olilarkin/librosa-wasm"); + +const librosa = await createLibrosa(); +``` + Matrices are row-major objects: ```ts diff --git a/packages/librosa-wasm/package-lock.json b/packages/librosa-wasm/package-lock.json index e3adabc..41495a4 100644 --- a/packages/librosa-wasm/package-lock.json +++ b/packages/librosa-wasm/package-lock.json @@ -1,12 +1,12 @@ { "name": "@olilarkin/librosa-wasm", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@olilarkin/librosa-wasm", - "version": "0.1.2", + "version": "0.1.3", "license": "ISC", "devDependencies": { "@types/node": "^20.12.12", diff --git a/packages/librosa-wasm/package.json b/packages/librosa-wasm/package.json index 3a388fb..85fef91 100644 --- a/packages/librosa-wasm/package.json +++ b/packages/librosa-wasm/package.json @@ -1,13 +1,21 @@ { "name": "@olilarkin/librosa-wasm", - "version": "0.1.2", + "version": "0.1.3", "description": "librosa.cpp compiled to WebAssembly with WASM SIMD and a TypeScript API.", "type": "module", - "main": "./dist/src/index.js", + "main": "./dist/src/index.cjs", + "module": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "exports": { ".": { - "types": "./dist/src/index.d.ts", + "import": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + }, + "require": { + "types": "./dist/src/index.d.cts", + "default": "./dist/src/index.cjs" + }, "default": "./dist/src/index.js" } }, @@ -20,8 +28,10 @@ "scripts": { "build:wasm": "emcmake cmake -S ../.. -B ../../build-wasm -G Ninja -DLIBROSA_BUILD_TESTS=OFF -DLIBROSA_BUILD_CROSSVAL_TESTS=OFF -DLIBROSA_BUILD_CLI=OFF -DLIBROSA_BUILD_WASM=ON -DLIBROSA_FFT_BACKEND=pffft && cmake --build ../../build-wasm --target librosa_wasm", "build:ts": "tsc -p tsconfig.json", - "build": "npm run build:wasm && npm run build:ts", - "test": "npm run build:ts && node --test dist/test/*.test.js", + "build:cjs": "node scripts/make-cjs.mjs", + "build": "npm run build:wasm && npm run build:ts && npm run build:cjs", + "test": "npm run build:ts && npm run build:cjs && node --test dist/test/*.test.js && npm run test:cjs", + "test:cjs": "node scripts/smoke.cjs", "prepublishOnly": "npm run build && npm test" }, "publishConfig": { diff --git a/packages/librosa-wasm/scripts/make-cjs.mjs b/packages/librosa-wasm/scripts/make-cjs.mjs new file mode 100644 index 0000000..eef4b24 --- /dev/null +++ b/packages/librosa-wasm/scripts/make-cjs.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +// Post-build step: emit a CommonJS build alongside the ESM one. +// +// Emscripten is configured with -sEXPORT_ES6=1 -sMODULARIZE=1, so the only +// glue it produces is an ES module (dist/wasm/librosa_wasm.mjs). CommonJS +// consumers (Electron main, Ableton Extension Host, plain `require()` in Node) +// cannot load that. Rather than ask emscripten to emit a second flavour (which +// would need a second, slow wasm link), we transform the existing ESM glue into +// a `.cjs` with a handful of pure-text substitutions, then write a CJS wrapper +// that mirrors src/index.ts. This runs after `build:wasm` + `build:ts`, needs no +// Emscripten toolchain, and is therefore safe to run in CI on the npm publish job. + +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** Apply a substitution, failing loudly if the expected text is missing. */ +function substitute(source, find, replace, label) { + if (typeof find === "string") { + if (!source.includes(find)) { + throw new Error( + `make-cjs: expected to find ${label} but it was not present — ` + + `the emscripten glue likely changed; update scripts/make-cjs.mjs.` + ); + } + return source.split(find).join(replace); + } + if (!find.test(source)) { + throw new Error( + `make-cjs: expected to match ${label} but nothing matched — ` + + `the emscripten glue likely changed; update scripts/make-cjs.mjs.` + ); + } + return source.replace(find, replace); +} + +// --- 1. Convert the emscripten ESM glue to CommonJS ------------------------- +// +// The transform must tolerate different emscripten releases, which emit the +// Node `require` shim two different ways: +// * 3.1.x: a top-level static `import{createRequire}from"module";` followed by +// `var require=createRequire(import.meta.url);` +// * 4.0.x: an inline `const{createRequire}=await import("module");` + +// `var require=createRequire(import.meta.url)` inside the Node branch +// Both forms are removed (a .cjs already has a real `require`), then every +// `import.meta.url` and the `export default` are rewritten. A post-conversion +// check fails loudly if any ES-module construct survives, so a future emscripten +// upgrade can't silently produce a broken `.cjs`. +{ + const mjsPath = resolve(pkgRoot, "dist/wasm/librosa_wasm.mjs"); + let glue = readFileSync(mjsPath, "utf8"); + + // A file: URL for the .cjs itself, so `new URL("librosa_wasm.wasm", …)` still + // resolves the sibling wasm binary at runtime. Named without an "import" + // substring so the leftover-ESM check below stays unambiguous. + const banner = + `const __cjsScriptUrl = require("url").pathToFileURL(__filename).href;\n`; + + // Drop the createRequire shim in either form, plus the `require` assignment it + // feeds. These are best-effort removals; the assertions below verify the net + // result regardless of which (if any) matched. + glue = glue + .replace(/import\s*\{\s*createRequire\s*\}\s*from\s*["']module["']\s*;?/g, "") + .replace(/const\s*\{\s*createRequire\s*\}\s*=\s*await\s+import\(\s*["']module["']\s*\)\s*;?/g, "") + .replace(/\b(?:var|const|let)\s+require\s*=\s*createRequire\(\s*import\.meta\.url\s*\)\s*;?/g, ""); + + // Every remaining `import.meta.url` -> our CJS stand-in. + glue = substitute(glue, /import\.meta\.url/g, "__cjsScriptUrl", "import.meta.url"); + + // `export default ;` -> `module.exports = ;` + glue = substitute(glue, /export\s+default\s+([A-Za-z0-9_$]+)\s*;?/, "module.exports = $1;", "the default export"); + + // Fail loudly if any ESM construct survived the conversion. + const leftovers = [ + [/\bcreateRequire\b/, "a stray createRequire reference"], + [/import\.meta/, "a stray import.meta"], + [/\bexport\s+(?:default|\{|\*|const|function|class)/, "a stray ESM export"], + [/(?:^|[;\n}])\s*import\s*[{*"'A-Za-z]/, "a stray static ESM import"] + ]; + for (const [re, label] of leftovers) { + if (re.test(glue)) { + throw new Error( + `make-cjs: ${label} survived ESM->CJS conversion — ` + + `the emscripten glue changed; update scripts/make-cjs.mjs.` + ); + } + } + + writeFileSync(resolve(pkgRoot, "dist/wasm/librosa_wasm.cjs"), banner + glue); +} + +// --- 2. Emit a CommonJS wrapper mirroring the compiled ESM entry ------------ +// +// The wrapper logic (arity table + Proxy) is identical; only the module syntax +// differs. We transform the already-compiled dist/src/index.js so the table +// stays single-sourced from src/index.ts. +{ + const esmPath = resolve(pkgRoot, "dist/src/index.js"); + let wrapper = readFileSync(esmPath, "utf8"); + + wrapper = substitute( + wrapper, + `import createModule from "../wasm/librosa_wasm.mjs";`, + `"use strict";\nconst createModule = require("../wasm/librosa_wasm.cjs");`, + "the wasm module import" + ); + + wrapper = substitute( + wrapper, + `export async function createLibrosa`, + `async function createLibrosa`, + "the createLibrosa export" + ); + + wrapper = substitute( + wrapper, + `export default createLibrosa;`, + `module.exports = createLibrosa;\nmodule.exports.createLibrosa = createLibrosa;\nmodule.exports.default = createLibrosa;`, + "the default export" + ); + + // The sourcemap belongs to the ESM build; drop the now-wrong reference. + wrapper = wrapper.replace(/\n?\/\/# sourceMappingURL=index\.js\.map\s*$/, "\n"); + + writeFileSync(resolve(pkgRoot, "dist/src/index.cjs"), wrapper); +} + +// --- 3. Emit type declarations matching the CJS `module.exports` ------------- +// +// The ESM `index.d.ts` describes a default + named export. The CJS runtime sets +// `module.exports = createLibrosa` (a callable), so the .d.cts must use +// `export =` with a merged namespace; otherwise `import x = require(pkg)` under +// node16/nodenext would type the require as a non-callable namespace object. +// The re-exported type names are derived from types.d.ts so this stays in sync. +{ + const typesDts = readFileSync(resolve(pkgRoot, "dist/src/types.d.ts"), "utf8"); + const typeNames = [ + ...typesDts.matchAll(/^export\s+(?:declare\s+)?(?:type|interface|class|enum)\s+([A-Za-z0-9_]+)/gm) + ].map((m) => m[1]); + if (typeNames.length === 0) { + throw new Error("make-cjs: found no exported type names in types.d.ts"); + } + + const indent = (names) => names.map((n) => ` ${n}`).join(",\n"); + const dcts = + `import type {\n${indent(typeNames)}\n} from "./types.js";\n` + + `declare function createLibrosa(options?: CreateLibrosaOptions): Promise;\n` + + `declare namespace createLibrosa {\n` + + ` export {\n` + + ` createLibrosa,\n` + + ` createLibrosa as default,\n` + + `${typeNames.map((n) => ` ${n}`).join(",\n")}\n` + + ` };\n` + + `}\n` + + `export = createLibrosa;\n`; + + writeFileSync(resolve(pkgRoot, "dist/src/index.d.cts"), dcts); +} + +console.log("make-cjs: wrote dist/wasm/librosa_wasm.cjs, dist/src/index.cjs, dist/src/index.d.cts"); diff --git a/packages/librosa-wasm/scripts/smoke.cjs b/packages/librosa-wasm/scripts/smoke.cjs new file mode 100644 index 0000000..2c0c285 --- /dev/null +++ b/packages/librosa-wasm/scripts/smoke.cjs @@ -0,0 +1,22 @@ +"use strict"; +// Smoke test for the CommonJS build: load the package exactly as a `require()` +// consumer (Electron main, Ableton Extension Host, plain Node) would, then run +// a real computation to prove the .cjs glue can locate and instantiate the wasm. +const assert = require("node:assert"); +const { test } = require("node:test"); + +const entry = require("../dist/src/index.cjs"); + +test("CJS entry exposes createLibrosa", () => { + assert.strictEqual(typeof entry, "function"); + assert.strictEqual(typeof entry.createLibrosa, "function"); + assert.strictEqual(entry.default, entry); +}); + +test("CJS build instantiates the wasm module and computes", async () => { + const librosa = await entry(); + const y = librosa.tone(440, { sr: 22050, duration: 0.1 }); + assert.ok(y.length > 0, "tone() should return samples"); + const mfcc = librosa.mfcc(y, { sr: 22050, nMfcc: 13 }); + assert.strictEqual(mfcc.rows, 13); +});