Skip to content

Commit ce1db11

Browse files
authored
Merge pull request #10 from olilarkin/wasm-cjs-build
Ship CommonJS build for librosa-wasm (0.1.3)
2 parents b52e777 + bacf6ba commit ce1db11

8 files changed

Lines changed: 214 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ jobs:
8282

8383
- uses: mymindstorm/setup-emsdk@v14
8484
with:
85-
version: 3.1.61
85+
version: 4.0.22
8686

8787
- name: Install npm dependencies
8888
working-directory: packages/librosa-wasm

.github/workflows/npm-publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
2828
- uses: mymindstorm/setup-emsdk@v14
2929
with:
30-
version: 3.1.61
30+
version: 4.0.22
3131

3232
- uses: actions/setup-node@v4
3333
with:

.github/workflows/web-pages.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939

4040
- uses: mymindstorm/setup-emsdk@v14
4141
with:
42-
version: 3.1.61
42+
version: 4.0.22
4343

4444
- name: Install WASM package dependencies
4545
working-directory: packages/librosa-wasm

packages/librosa-wasm/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ const y = librosa.tone(440, { sr: 22050, duration: 1.0 });
1010
const mfcc = librosa.mfcc(y, { sr: 22050, nMfcc: 13 });
1111
```
1212

13+
The package ships both an ES module and a CommonJS build, so it works directly
14+
from `import` (ESM, bundlers, browsers) and from `require` (Electron main, plain
15+
Node, Ableton Extension Host) with no extra conversion:
16+
17+
```js
18+
const { createLibrosa } = require("@olilarkin/librosa-wasm");
19+
20+
const librosa = await createLibrosa();
21+
```
22+
1323
Matrices are row-major objects:
1424

1525
```ts

packages/librosa-wasm/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/librosa-wasm/package.json

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
{
22
"name": "@olilarkin/librosa-wasm",
3-
"version": "0.1.2",
3+
"version": "0.1.3",
44
"description": "librosa.cpp compiled to WebAssembly with WASM SIMD and a TypeScript API.",
55
"type": "module",
6-
"main": "./dist/src/index.js",
6+
"main": "./dist/src/index.cjs",
7+
"module": "./dist/src/index.js",
78
"types": "./dist/src/index.d.ts",
89
"exports": {
910
".": {
10-
"types": "./dist/src/index.d.ts",
11+
"import": {
12+
"types": "./dist/src/index.d.ts",
13+
"default": "./dist/src/index.js"
14+
},
15+
"require": {
16+
"types": "./dist/src/index.d.cts",
17+
"default": "./dist/src/index.cjs"
18+
},
1119
"default": "./dist/src/index.js"
1220
}
1321
},
@@ -20,8 +28,10 @@
2028
"scripts": {
2129
"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",
2230
"build:ts": "tsc -p tsconfig.json",
23-
"build": "npm run build:wasm && npm run build:ts",
24-
"test": "npm run build:ts && node --test dist/test/*.test.js",
31+
"build:cjs": "node scripts/make-cjs.mjs",
32+
"build": "npm run build:wasm && npm run build:ts && npm run build:cjs",
33+
"test": "npm run build:ts && npm run build:cjs && node --test dist/test/*.test.js && npm run test:cjs",
34+
"test:cjs": "node scripts/smoke.cjs",
2535
"prepublishOnly": "npm run build && npm test"
2636
},
2737
"publishConfig": {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env node
2+
// Post-build step: emit a CommonJS build alongside the ESM one.
3+
//
4+
// Emscripten is configured with -sEXPORT_ES6=1 -sMODULARIZE=1, so the only
5+
// glue it produces is an ES module (dist/wasm/librosa_wasm.mjs). CommonJS
6+
// consumers (Electron main, Ableton Extension Host, plain `require()` in Node)
7+
// cannot load that. Rather than ask emscripten to emit a second flavour (which
8+
// would need a second, slow wasm link), we transform the existing ESM glue into
9+
// a `.cjs` with a handful of pure-text substitutions, then write a CJS wrapper
10+
// that mirrors src/index.ts. This runs after `build:wasm` + `build:ts`, needs no
11+
// Emscripten toolchain, and is therefore safe to run in CI on the npm publish job.
12+
13+
import { readFileSync, writeFileSync } from "node:fs";
14+
import { fileURLToPath } from "node:url";
15+
import { dirname, resolve } from "node:path";
16+
17+
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
18+
19+
/** Apply a substitution, failing loudly if the expected text is missing. */
20+
function substitute(source, find, replace, label) {
21+
if (typeof find === "string") {
22+
if (!source.includes(find)) {
23+
throw new Error(
24+
`make-cjs: expected to find ${label} but it was not present — ` +
25+
`the emscripten glue likely changed; update scripts/make-cjs.mjs.`
26+
);
27+
}
28+
return source.split(find).join(replace);
29+
}
30+
if (!find.test(source)) {
31+
throw new Error(
32+
`make-cjs: expected to match ${label} but nothing matched — ` +
33+
`the emscripten glue likely changed; update scripts/make-cjs.mjs.`
34+
);
35+
}
36+
return source.replace(find, replace);
37+
}
38+
39+
// --- 1. Convert the emscripten ESM glue to CommonJS -------------------------
40+
//
41+
// The transform must tolerate different emscripten releases, which emit the
42+
// Node `require` shim two different ways:
43+
// * 3.1.x: a top-level static `import{createRequire}from"module";` followed by
44+
// `var require=createRequire(import.meta.url);`
45+
// * 4.0.x: an inline `const{createRequire}=await import("module");` +
46+
// `var require=createRequire(import.meta.url)` inside the Node branch
47+
// Both forms are removed (a .cjs already has a real `require`), then every
48+
// `import.meta.url` and the `export default` are rewritten. A post-conversion
49+
// check fails loudly if any ES-module construct survives, so a future emscripten
50+
// upgrade can't silently produce a broken `.cjs`.
51+
{
52+
const mjsPath = resolve(pkgRoot, "dist/wasm/librosa_wasm.mjs");
53+
let glue = readFileSync(mjsPath, "utf8");
54+
55+
// A file: URL for the .cjs itself, so `new URL("librosa_wasm.wasm", …)` still
56+
// resolves the sibling wasm binary at runtime. Named without an "import"
57+
// substring so the leftover-ESM check below stays unambiguous.
58+
const banner =
59+
`const __cjsScriptUrl = require("url").pathToFileURL(__filename).href;\n`;
60+
61+
// Drop the createRequire shim in either form, plus the `require` assignment it
62+
// feeds. These are best-effort removals; the assertions below verify the net
63+
// result regardless of which (if any) matched.
64+
glue = glue
65+
.replace(/import\s*\{\s*createRequire\s*\}\s*from\s*["']module["']\s*;?/g, "")
66+
.replace(/const\s*\{\s*createRequire\s*\}\s*=\s*await\s+import\(\s*["']module["']\s*\)\s*;?/g, "")
67+
.replace(/\b(?:var|const|let)\s+require\s*=\s*createRequire\(\s*import\.meta\.url\s*\)\s*;?/g, "");
68+
69+
// Every remaining `import.meta.url` -> our CJS stand-in.
70+
glue = substitute(glue, /import\.meta\.url/g, "__cjsScriptUrl", "import.meta.url");
71+
72+
// `export default <name>;` -> `module.exports = <name>;`
73+
glue = substitute(glue, /export\s+default\s+([A-Za-z0-9_$]+)\s*;?/, "module.exports = $1;", "the default export");
74+
75+
// Fail loudly if any ESM construct survived the conversion.
76+
const leftovers = [
77+
[/\bcreateRequire\b/, "a stray createRequire reference"],
78+
[/import\.meta/, "a stray import.meta"],
79+
[/\bexport\s+(?:default|\{|\*|const|function|class)/, "a stray ESM export"],
80+
[/(?:^|[;\n}])\s*import\s*[{*"'A-Za-z]/, "a stray static ESM import"]
81+
];
82+
for (const [re, label] of leftovers) {
83+
if (re.test(glue)) {
84+
throw new Error(
85+
`make-cjs: ${label} survived ESM->CJS conversion — ` +
86+
`the emscripten glue changed; update scripts/make-cjs.mjs.`
87+
);
88+
}
89+
}
90+
91+
writeFileSync(resolve(pkgRoot, "dist/wasm/librosa_wasm.cjs"), banner + glue);
92+
}
93+
94+
// --- 2. Emit a CommonJS wrapper mirroring the compiled ESM entry ------------
95+
//
96+
// The wrapper logic (arity table + Proxy) is identical; only the module syntax
97+
// differs. We transform the already-compiled dist/src/index.js so the table
98+
// stays single-sourced from src/index.ts.
99+
{
100+
const esmPath = resolve(pkgRoot, "dist/src/index.js");
101+
let wrapper = readFileSync(esmPath, "utf8");
102+
103+
wrapper = substitute(
104+
wrapper,
105+
`import createModule from "../wasm/librosa_wasm.mjs";`,
106+
`"use strict";\nconst createModule = require("../wasm/librosa_wasm.cjs");`,
107+
"the wasm module import"
108+
);
109+
110+
wrapper = substitute(
111+
wrapper,
112+
`export async function createLibrosa`,
113+
`async function createLibrosa`,
114+
"the createLibrosa export"
115+
);
116+
117+
wrapper = substitute(
118+
wrapper,
119+
`export default createLibrosa;`,
120+
`module.exports = createLibrosa;\nmodule.exports.createLibrosa = createLibrosa;\nmodule.exports.default = createLibrosa;`,
121+
"the default export"
122+
);
123+
124+
// The sourcemap belongs to the ESM build; drop the now-wrong reference.
125+
wrapper = wrapper.replace(/\n?\/\/# sourceMappingURL=index\.js\.map\s*$/, "\n");
126+
127+
writeFileSync(resolve(pkgRoot, "dist/src/index.cjs"), wrapper);
128+
}
129+
130+
// --- 3. Emit type declarations matching the CJS `module.exports` -------------
131+
//
132+
// The ESM `index.d.ts` describes a default + named export. The CJS runtime sets
133+
// `module.exports = createLibrosa` (a callable), so the .d.cts must use
134+
// `export =` with a merged namespace; otherwise `import x = require(pkg)` under
135+
// node16/nodenext would type the require as a non-callable namespace object.
136+
// The re-exported type names are derived from types.d.ts so this stays in sync.
137+
{
138+
const typesDts = readFileSync(resolve(pkgRoot, "dist/src/types.d.ts"), "utf8");
139+
const typeNames = [
140+
...typesDts.matchAll(/^export\s+(?:declare\s+)?(?:type|interface|class|enum)\s+([A-Za-z0-9_]+)/gm)
141+
].map((m) => m[1]);
142+
if (typeNames.length === 0) {
143+
throw new Error("make-cjs: found no exported type names in types.d.ts");
144+
}
145+
146+
const indent = (names) => names.map((n) => ` ${n}`).join(",\n");
147+
const dcts =
148+
`import type {\n${indent(typeNames)}\n} from "./types.js";\n` +
149+
`declare function createLibrosa(options?: CreateLibrosaOptions): Promise<Librosa>;\n` +
150+
`declare namespace createLibrosa {\n` +
151+
` export {\n` +
152+
` createLibrosa,\n` +
153+
` createLibrosa as default,\n` +
154+
`${typeNames.map((n) => ` ${n}`).join(",\n")}\n` +
155+
` };\n` +
156+
`}\n` +
157+
`export = createLibrosa;\n`;
158+
159+
writeFileSync(resolve(pkgRoot, "dist/src/index.d.cts"), dcts);
160+
}
161+
162+
console.log("make-cjs: wrote dist/wasm/librosa_wasm.cjs, dist/src/index.cjs, dist/src/index.d.cts");
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"use strict";
2+
// Smoke test for the CommonJS build: load the package exactly as a `require()`
3+
// consumer (Electron main, Ableton Extension Host, plain Node) would, then run
4+
// a real computation to prove the .cjs glue can locate and instantiate the wasm.
5+
const assert = require("node:assert");
6+
const { test } = require("node:test");
7+
8+
const entry = require("../dist/src/index.cjs");
9+
10+
test("CJS entry exposes createLibrosa", () => {
11+
assert.strictEqual(typeof entry, "function");
12+
assert.strictEqual(typeof entry.createLibrosa, "function");
13+
assert.strictEqual(entry.default, entry);
14+
});
15+
16+
test("CJS build instantiates the wasm module and computes", async () => {
17+
const librosa = await entry();
18+
const y = librosa.tone(440, { sr: 22050, duration: 0.1 });
19+
assert.ok(y.length > 0, "tone() should return samples");
20+
const mfcc = librosa.mfcc(y, { sr: 22050, nMfcc: 13 });
21+
assert.strictEqual(mfcc.rows, 13);
22+
});

0 commit comments

Comments
 (0)