Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/web-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/librosa-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/librosa-wasm/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 15 additions & 5 deletions packages/librosa-wasm/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
},
Expand All @@ -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": {
Expand Down
162 changes: 162 additions & 0 deletions packages/librosa-wasm/scripts/make-cjs.mjs
Original file line number Diff line number Diff line change
@@ -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 <name>;` -> `module.exports = <name>;`
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<Librosa>;\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");
22 changes: 22 additions & 0 deletions packages/librosa-wasm/scripts/smoke.cjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading