Skip to content

Building from source ​

The repository is a pnpm workspace:

PathWhat
packages/spellophoneThe library: C glue, wasm build, TypeScript, data packer
apps/docsThis documentation site (VitePress)
examples/nodeNode.js example
examples/browserVite 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:

bash
mise install

Then:

bash
pnpm install
pnpm build             # fetch:espeak, then build:data, build:wasm, build:ts

pnpm 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:

bash
pnpm --filter @spelling-creator/spellophone build:ts   # tsdown
pnpm test
pnpm typecheck
pnpm fmt && pnpm lint                                  # oxfmt and oxlint

Rerun 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:

  1. build:data configures espeak-ng's own CMake project natively in build/native, builds its data target (which runs the espeak-ng CLI to compile dictsource/ and phsource/), then packs the result into packages/spellophone/espeak-ng-data/ as gzipped files plus manifest.json. See scripts/build-data.ts and scripts/pack-data.ts.
  2. build:wasm configures packages/spellophone/native/CMakeLists.txt with emcmake in build/wasm and builds wasm/espeak-ng.js, wasm/espeak-ng.wasm and wasm/espeak-ng.d.ts, plus the wasm2js module (wasm/espeak-ng.legacy.js and wasm/espeak-ng.legacy.wasm.js, see below). See scripts/build-wasm.ts.
  3. build:ts bundles src/ into dist/ with tsdown, with declarations, and builds the ES5 legacy build into dist/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):

  • MODULARIZE and EXPORT_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 with fopen, from MEMFS.
  • ALLOW_MEMORY_GROWTH and 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 .wasm with import.meta.url, which bundlers then try to emit as an asset and which ES5 cannot express. native/legacy-prefix.js and native/legacy-export.js (passed with --extern-pre-js and --extern-post-js) make it an ES module anyway, keep Emscripten's CommonJS/AMD export block from running, and give abort() the WebAssembly.RuntimeError it throws, which is plain Error as in Emscripten's own wasm2js mode.
  • ENVIRONMENT=web,worker: no node: imports.
  • WASM_BIGINT=0: 64-bit values cross between JavaScript and wasm as pairs of numbers. ES5 has no BigInt and wasm2js cannot produce it. Emscripten marks this deprecated too, because only -sWASM=0 is meant to need it, so the legacy link prints a warning about it. That warning is expected. It is not silenced, since -Wno-deprecated would hide every other deprecated setting as well.
  • TEXTDECODER=1: falls back to plain JavaScript where TextDecoder is missing.
  • DYNAMIC_EXECUTION=0: no eval, 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:

  1. 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.
  2. 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 with qsort, 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 ​

  1. Change tag in vendor/espeak-ng.json.
  2. pnpm build.
  3. Run the tests. Phoneme output for a word can change between espeak-ng releases, so a failing assertion may just need updating.
  4. Commit, and note the espeak-ng version in the changelog.

Checking the browser example ​

bash
pnpm example:browser

opens 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).

Released under the GPL-3.0-or-later license.