Building from source
The repository is a pnpm workspace:
| Path | What |
|---|---|
packages/spellophone | The library: C glue, wasm build, TypeScript, data packer |
apps/docs | This documentation site (VitePress) |
examples/node | Node.js example |
examples/browser | Vite browser example |
vendor/ | The pinned espeak-ng version (fetched, not committed) |
Setting up
The wasm and the data are build output and not in git, so the first build compiles them from espeak-ng's sources. Prerequisites:
- Node.js 24+ and pnpm
- Emscripten (
emcc,emcmake) - CMake and a host C/C++ compiler (clang or gcc), for the native espeak-ng build that compiles the dictionaries
- git
mise.toml pins all of them except the C compiler, so with mise it is one command:
mise installThen:
pnpm install
pnpm build # fetch:espeak, then build:data, build:wasm, build:tspnpm fetch:espeak clones espeak-ng at the tag in vendor/espeak-ng.json and leaves an existing checkout at that tag alone. The whole build takes well under a minute.
After that, day to day:
pnpm --filter @spelling-creator/spellophone build:ts # tsdown
pnpm test
pnpm typecheck
pnpm fmt && pnpm lint # oxfmt and oxlintRerun pnpm build after bumping espeak-ng or changing anything in native/ or the build scripts.
What the build does
The three steps, and what they produce:
build:dataconfigures espeak-ng's own CMake project natively inbuild/native, builds itsdatatarget (which runs the espeak-ng CLI to compiledictsource/andphsource/), then packs the result intopackages/spellophone/espeak-ng-data/as gzipped files plusmanifest.json. Seescripts/build-data.tsandscripts/pack-data.ts.build:wasmconfigurespackages/spellophone/native/CMakeLists.txtwithemcmakeinbuild/wasmand buildswasm/espeak-ng.js,wasm/espeak-ng.wasmandwasm/espeak-ng.d.ts, plus the wasm2js module (wasm/espeak-ng.legacy.jsandwasm/espeak-ng.legacy.wasm.js, see below). Seescripts/build-wasm.ts.build:tsbundlessrc/intodist/with tsdown, with declarations, and builds the ES5 legacy build intodist/legacy/.
Reproducibility
The build is reproducible: the same sources give the same bytes, on any machine. CI builds the wasm and data on Linux and on macOS and fails if the two differ in any file. The release workflow builds the published package from the tag the same way, so anyone can rebuild a release and compare it with the tarball on npm or on the GitHub release.
How the wasm build works
native/CMakeLists.txt compiles the espeak-ng library sources (plus its bundled ucd-tools and speechPlayer) directly, rather than through espeak-ng's own CMake project, which also wants to build the CLI, fetch libsonic from GitHub and run the data compiler. Features that cannot work in wasm are off: async mode (threads), MBROLA (spawns a process), libpcaudio (sound device) and libsonic (not needed at normal rates).
native/glue.c is the only C written for this project. It exports a flat set of sp_* functions: initialize, synthesize into growable buffers, read the buffers back, list and select voices, set parameters, text to phonemes. Audio and events are collected in C during the synchronous espeak_Synth call and copied out afterwards, so the JavaScript side needs no callbacks.
native/include/wchar.h is a shim around espeak-ng's compat header for musl, Emscripten's libc. espeak-ng redefines iswalpha() and friends as macros for its own Unicode-aware versions, and musl's <wchar.h> then redeclares the same names, which the macros rewrite into conflicting prototypes. The shim lifts the macros while the system headers are read and restores them after.
Emscripten options that matter (all in the CMake file):
MODULARIZEandEXPORT_ES6: the glue is an ES module exporting a factory.ENVIRONMENT=web,worker,node: one glue file for every runtime.FORCE_FILESYSTEM: espeak-ng reads its data withfopen, from MEMFS.ALLOW_MEMORY_GROWTHand a 1 MB stack: espeak-ng keeps large buffers on the stack.--emit-tsd: generates the module's TypeScript declarations.
The wasm2js module and the legacy build
The same program is linked a second time, as the spellophone-legacy target, for engines without WebAssembly (see Without WebAssembly). That link still produces a .wasm file. scripts/build-wasm.ts then runs Binaryen's standalone wasm2js, which ships with the emsdk, to turn it into JavaScript, and src/wasm2js.ts hands the result to the Emscripten glue through its instantiateWasm hook, so the glue never touches WebAssembly.
Emscripten can do this itself with -sWASM=0, but that setting is deprecated and may be removed, while the standalone wasm2js tool is staying. The legacy link differs from the main one in these options:
- No
EXPORT_ES6: that glue would locate the.wasmwithimport.meta.url, which bundlers then try to emit as an asset and which ES5 cannot express.native/legacy-prefix.jsandnative/legacy-export.js(passed with--extern-pre-jsand--extern-post-js) make it an ES module anyway, keep Emscripten's CommonJS/AMD export block from running, and giveabort()theWebAssembly.RuntimeErrorit throws, which is plainErroras in Emscripten's own wasm2js mode. ENVIRONMENT=web,worker: nonode:imports.WASM_BIGINT=0: 64-bit values cross between JavaScript and wasm as pairs of numbers. ES5 has no BigInt andwasm2jscannot produce it. Emscripten marks this deprecated too, because only-sWASM=0is meant to need it, so the legacy link prints a warning about it. That warning is expected. It is not silenced, since-Wno-deprecatedwould hide every other deprecated setting as well.TEXTDECODER=1: falls back to plain JavaScript whereTextDecoderis missing.DYNAMIC_EXECUTION=0: noeval, so a strict Content-Security-Policy is fine.
wasm2js is given the features the module was compiled with (bulk memory, saturating float-to-int conversions, sign extension), since the optimized binary no longer records them, and lowers them to plain JavaScript.
dist/wasm2js.js (the ./wasm2js export) imports those two files as they are. The legacy build bundles them instead: tsdown.config.ts builds src/legacy.ts as one ES module and one IIFE, and scripts/swc-es5.ts lowers it with swc, because Oxc, which tsdown uses otherwise, does not go below ES2015. That plugin runs twice:
- On each module of our own, swc lowers the syntax and adds imports of the core-js polyfills for the built-ins it uses that IE 11 lacks, which the bundler then pulls in.
- On the finished chunk, swc lowers what the bundler added itself and minifies. Rolldown's own minifier is off, since it would reprint the result.
The legacy build has no source maps: they would be over 5 MB each, mostly the wasm2js module repeated.
src/legacy.test.ts rebuilds dist/legacy (the tsdown configs named legacy) and checks the result: both files must parse as ES5, and the IIFE must work in a node:vm context with WebAssembly, fetch, Promise, Map, Symbol and the other ES2015 and later built-ins IE 11 lacks deleted.
Patches to espeak-ng
vendor/patches/*.patch are applied to the checkout by pnpm fetch:espeak (idempotently, so running it again is safe). Keep them few and small; each one is a candidate to send upstream.
0001-deterministic-dictionary-compile.patch: the dictionary compiler sorts rules withqsort, whose order for tied entries depends on the C library, so the same sources compiled to different bytes on macOS and Linux. The patch makes both sorts stable. Without it the reproducibility check in CI cannot pass for a build made on a Mac.
To change a patch, edit the files in vendor/espeak-ng, then regenerate it with git -C vendor/espeak-ng diff > vendor/patches/<name>.patch.
Bumping espeak-ng
- Change
taginvendor/espeak-ng.json. pnpm build.- Run the tests. Phoneme output for a word can change between espeak-ng releases, so a failing assertion may just need updating.
- Commit, and note the espeak-ng version in the changelog.
Checking the browser example
pnpm example:browseropens a Vite dev server. Append ?cdn to the URL to fetch the data from jsDelivr instead of the local copy (only works for released versions).