Skip to content

Brian B. Tung

← Blog

Kokoro-82M in the browser on an M1 Max: WebGPU, the WASM fallback, and what each costs

Jump to 9

Three numbers decide whether text-to-speech can run in front of a user without a server: real-time factor, cold load time, and on-disk size. All three were measured here for Kokoro-82M while building a browser extension that reads a video page's subtitle track, synthesizes speech from it locally, and plays it in sync.

Two results were not the expected ones. The WASM fallback runs at RTF 2.0, twice as slow as real time, in the single-threaded configuration a browser extension actually gets. And on CPU the quantized model is slower than full precision, not faster.

Everything measured below is speech the model generated. The source text is the caption track of NASA Science Live: Aurora Glow, Electric Flow & the EZIE Mission (Episode 63, March 24, 2025), a U.S. Government work in the public domain under 17 U.S.C. §105. NASA lists it with a download link on its episode archive; the video is on NASA's YouTube channel, where NASA's own episode page now redirects. The episode download includes a caption file; the copy used here is republished as nasa-captions.vtt. It appears to be machine-generated rather than authored by a captioner, which matters in the companion post where cue boundaries set a timing budget.

Setup

Machine

MacBook Pro 16-inch, 2021 (MacBookPro18,2), Apple M1 Max: 10 CPU cores (8 performance + 2 efficiency), 32 GPU cores, 64 GB unified memory, macOS 26.5.2.

State

AC power, Low Power Mode off, machine otherwise idle

Model

Kokoro-82M v1.0 ONNX export, Apache-2.0, from onnx-community/Kokoro-82M-v1.0-ONNX, fetched August 2026

Runtimes

onnxruntime-web via transformers.js v4 (browser, WASM and WebGPU); onnxruntime-node (CPU) for the Node rows

Workload

8 caption lines from the episode above, 29.0 s of generated audio per pass

Date

August 2026

Power state is listed because it moves these numbers more than anything else measured here, which is less obvious than the rest of the table; a section below quantifies it.

Footprint

Asset

Size

model_quantized.onnx (q8)

88.1 MB

model.onnx (fp32)

310.5 MB

Voice embeddings, one file per voice at 510 KB each

24.9 MB (50 voices)

Neither size is a hard limit for a browser extension, and weights can be downloaded on first run rather than bundled, so this is a packaging preference more than a constraint. Voices are separate files of identical size, so shipping one voice costs 510 KB rather than the whole 24.9 MB.

Browser throughput

Real-time factor is seconds of compute per second of audio produced. Under 1.0 the model generates faster than playback, which is the condition for staying ahead of a video.

Measured in headless Chromium against the bundled weights:

Runtime

Threads

Cold load

RTF median

Range

WebGPU, fp32

1

2.27 s

0.105

0.104–0.112

WASM, q8

1

4.17 s

2.002

1.985–2.038

WASM, q8

4

2.80 s

0.998

0.987–1.007

WASM, q8

8

2.78 s

0.830

0.827–0.847

Spread within a configuration is small next to the gaps between them, so the differences are structural rather than noise.

The single-threaded WASM figure is the one that matters, because that is what ships. Multi-threaded WASM in onnxruntime-web requires SharedArrayBuffer, which requires the page to be cross-origin isolated (COOP/COEP headers). An extension running inside arbitrary pages cannot rely on that, so it requests one thread. At RTF 2.0 the renderer falls behind and can only recover during silences.

Threading helps up to a point: 1 to 4 threads roughly halves the cost, and 4 to 8 gains another 17%. Four threads lands on real time almost exactly, close enough that repeat measurement puts it fractionally either side of 1.0, and eight threads at 0.830 is faster than real time but without much headroom. The gap between the two paths on this hardware is about 19×, and what that means for a given product depends on whether it can tolerate falling behind.

The binding constraint is single-thread throughput, so an environment that permits cross-origin isolation, and therefore threads, gains more than a faster clock would.

Browser throughput by configuration 0 0.5 1 1.5 2 2.5 real-time factor (seconds of compute per second of audio) webgpu, fp32, 1 thread 0.105 wasm, q8, 8 threads 0.83 wasm, q8, 4 threads 0.998 wasm, q8, 1 thread 2.002 real time

Raw data: throughput-browser.csv.

Low Power Mode costs 10 to 19%

Every configuration above was also measured with macOS Low Power Mode enabled, on AC power, same quiet machine:

Runtime

Threads

Normal

Low Power Mode

Cost

WebGPU, fp32

1

0.105

0.116

+10%

WASM, q8

1

2.002

2.388

+19%

WASM, q8

4

0.998

1.154

+16%

WASM, q8

8

0.830

0.955

+15%

The cost sits between 10 and 19% across the configurations, so it is roughly proportional rather than specific to any runtime, which makes it easy to correct for and easy to forget. The GPU path gives up the least, which is what you would expect if the throttle is mostly applied to the CPU.

It does not flip any configuration across the real-time line here, but it does remove what little margin the CPU path has: four threads goes from approximately real time to clearly slower than it, and eight threads from a modest margin to almost none. On a laptop conserving battery, the CPU path has no headroom left.

The practical lesson is unchanged: a benchmark that does not record power state can report materially different numbers for identical code, and a laptop is the machine most likely to be throttled when someone actually runs your software.

The Node figures move the same way (q8 0.499 to 0.585, fp32 0.206 to 0.237), so this is a property of the machine rather than of a runtime.

Confirmed in an ordinary browser, not just on the bench

The bench measures synthesis in isolation. To see what that costs during playback, the extension was run against the same video, from the same point, for the same 150-second window. The only difference between the runs is --disable-gpu, which is the flag that actually removes the WebGPU adapter (--disable-webgpu and --disable-features=WebGPU both leave it available, which would have quietly invalidated the comparison).

The starting point is deliberate: 24 seconds in, past an intro gap, where the captions are back-to-back. RTF above 1.0 does not make a renderer late where dialogue is sparse, because the silences let it catch up, so a sparse stretch would understate the cost.

Each generated clip records the gap between the moment it was ready and the moment it was due to be spoken. A clip finished before its line comes up is early, which is what a renderer wants; a clip finished after its line has already passed is late, and the listener hears the dub trailing the picture.

Both engines start behind, because the model is still loading while the video plays. That is a one-time cost, so it is worth separating from how each engine behaves once warm.

Cold start. On WebGPU the first two or three clips are late, the first of them consistently by about 23.8 seconds, after which the dub catches up and stays caught up. On WASM there is no equivalent moment: no clip was ever ready before its cue, in any run.

After the cold start, the two engines are not on the same curve at all:

Engine

Clips after warm-up

Median

Best

Worst

Clips late

webgpu/kokoro-fp32

1,230

58.5 s early

59.8 s early

0.1 s early

0 of 1,230

wasm/kokoro-q8

361

11.8 s late

3.0 s late

19.1 s late

361 of 361

Per-clip render lateness, WebGPU against the WASM fallback 0 10 20 30 40 clip index -60 -40 -20 0 20 40 seconds late (negative = ready early) on time webgpu (30 runs, median solid) wasm (30 runs, median solid)

Two things the table cannot show. The WASM lateness oscillates between roughly 3 and 19 seconds rather than trending toward zero, so there is no point at which it would have caught up. And each WASM run produced 13 or 14 clips where WebGPU produced 43 or 44, which is the throughput gap appearing as work never done rather than as work done late.

These figures pool every clip from repeated runs of both arms, and the pattern was the same in each of them.

The same test on a sparse stretch of the same video looks very different, which is the practical point. In an earlier hand-driven session on sparse dialogue, roughly one line every ten seconds, the WASM path did not sound late at all. So RTF above 1.0 does not mean speech is always late; it means the renderer cannot recover a deficit, and whether that shows depends on how continuous the dialogue is. The companion post measures that on this track: a median of 17.4 characters per second, and 34.9 in the densest stretch.

Raw data: engine-lateness.json.

Node throughput, and the quantization surprise

The same model through onnxruntime-node on CPU, Low Power Mode off:

Config

Cold load

RTF median

Range

Spread

q8 (quantized)

1.26 s

0.499

0.494–0.509

3.0%

fp32

0.67 s

0.206

0.205–0.208

1.5%

The quantized model is 3.5× smaller and 2.4× slower. Quantization is a size optimization here, not a speed one. Int8 kernels are not uniformly faster than tuned float kernels, and at 82M parameters the memory saving does not appear to recover the difference; that explanation was not verified by profiling the kernels, so treat it as a plausible reading rather than a conclusion.

The audio is effectively identical: same duration to the sample, F0 within 1.6%, spectral centroid within 1.5%. A 29-second dubbed passage rendered end to end in each precision, two voices alternating on caption boundaries:

Every sample on this page is also available uncompressed: same path,.wav instead of.mp3 (24 kHz mono). The mp3s are 96 kbps, which is transparent enough for pace and pronunciation but not necessarily for the finer comparisons below, so the originals are there to check against.

The lines, speaker assignment and onsets for this passage are in passage-transcript.md; the full caption file is nasa-captions.vtt. The source track has no speaker tags, so the two voices are an assignment for the demo, not a claim about who spoke.

Which precision to ship is a trade between 88 MB and 310 MB against roughly 2.4× on the CPU path, and the answer depends on how much that path matters to you. The transferable part is only that "quantize to make it faster" did not hold here, so it is worth measuring in your own setting.

Load time is mostly file I/O

First fp32 load in a session took 8.1 s; a second load of the same file took 0.68 s. That difference is the operating system's page cache, not the runtime. In the browser, cold loads measured 2.2–4.2 s.

The response that worked was not to make loading faster but to start it while the user is already waiting: the extension begins loading when someone opens a page it is enabled for, well before any subtitle track has been fetched. Loading also runs in a dedicated worker, because the GPU runtime's initialization has long synchronous phases that froze the pipeline for 60–80 s when it shared a thread.

What the three engines sound like

Numbers do not answer "is it good enough". The same passage below was rendered by every engine this project evaluated, through the same browser runtime, on the same caption timing: a 40-second host-and-guest exchange from the source broadcast, with lines and speaker turns in crosstalk-transcript.md.

Engine

Load

RTF

Voices

Sample

Kokoro q8, WASM

5.2 s

2.41

two

listen

Kokoro fp32, WebGPU

2.2 s

0.13

two

listen

Chatterbox Turbo, WebGPU

84 s

1.30

one

listen

the original broadcast

listen

Uncompressed: Kokoro q8 · Kokoro fp32 · Chatterbox · original broadcast

The last row is the original broadcast, extracted from the same 40 seconds of the source, as a reference point for the other three.

Each render sits on the source's own caption timing and is fitted the way the shipped pipeline fits it, so what you hear includes the scheduler's behavior as well as the engine's. Two things in particular are the scheduler, not the model:

Some lines are spoken very fast. Five or six of the fourteen are time-compressed to fit their slot. One hits the pipeline's 3× ceiling and still overruns: the caption "and you are" occupies 0.37 seconds in the source, because the speaker was talking over someone, and Kokoro needs 1.43 seconds to say it. Chatterbox needs 0.76 seconds for the same three words, so it only compresses 2.07×. That difference in natural pace matters, because a slower voice reaches the compression ceiling sooner and starts losing intelligibility on tight lines.

The pauses between lines are real. They are the speakers' own pauses, and synthesized speech runs about 21% faster than the original delivery here, so every gap in the original comes out longer. The original recording above makes the effect easy to hear.

Chatterbox Turbo (Resemble AI, MIT-licensed) is the engine this project evaluated and did not ship. It is much larger, about 1.5 GB against 88 MB, and it conditions on a reference audio clip rather than a voice embedding, so the single bundled reference means both speakers in its sample share one voice. At RTF 1.30 it cannot be driven live by a renderer that has to keep up with a playhead, and an 84-second cold load is its own problem, but the sample is here because "too slow for this use" is not the same as "worse", and that is a judgment worth leaving to you.

These renders also check the throughput table: they were produced by a different code path from the benchmark above and reproduce its numbers (2.41 against 2.002 for WASM, 0.13 against 0.105 for WebGPU, the difference explained by these lines being shorter on average, so per-call overhead is amortized over less audio).

Reproducing this

The model is public: onnx-community/Kokoro-82M-v1.0-ONNX on Hugging Face (Apache-2.0), loaded through transformers.js v4 in the browser and onnxruntime-node on the CPU path. The caption file is nasa-captions.vtt.

The method, stated exactly:

  1. Take 8 consecutive caption lines longer than 40 characters from a dense region of the track. Real captions rather than a stock paragraph, so line lengths are realistic.
  2. Load the model, synthesize one warm-up line, and time the load from process start to the completion of that first call.
  3. For each pass, synthesize all 8 lines, summing wall-clock synthesis time and the duration of the returned audio. RTF is their ratio.
  4. Repeat the pass and report the median with the full range. A single number hides throttling, which on a laptop is the largest effect present.
  5. Record power mode, power source, and load average alongside the numbers.

Synthesis itself is deterministic: the same text and voice produce a bit-identical waveform every time, verified by hashing repeated runs. So anything derived from the audio (durations, pitch, brightness) reproduces exactly, and only the timing figures — real-time factor, load, and clip lateness — carry run-to-run spread. That is why the tables above quote a range for throughput and not for anything measured off the waveform.

For the browser figures, serve the page over localhost (WebGPU is gated to secure contexts) and, for the thread sweep, set Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, or the WASM backend stays single-threaded no matter what thread count is requested.

Data files: throughput-browser.csv · throughput.csv · model-footprint.csv · crosstalk-models.json · summary.json

One machine, one model version, one runtime version. A Windows laptop with a discrete GPU, an integrated-graphics machine, or a phone would most likely change the WebGPU-versus-WASM ratio more than anything else here.

The companion posts cover the voices themselves and what happens when this speech has to fit a timeline someone else wrote.