Skip to content

November 8, 2026

Underdog Woof: the 40MB TTS that ships no runtime — and what porting it taught us

Underdog AI released a tiny TTS model with no runtime at all — numpy weights and a readable reference. We ported it to typed-array TypeScript, then to 22 hand-written WebGPU kernels. Here is what their approach gave us.

On November 7, 2026, Underdog AI released Woof, a text-to-speech model small enough to email — and, per Underdog’s own evals, one of the most accurate small TTS systems ever published. We have no affiliation with Underdog; TTSLab is an independent, open-source browser lab for voice models. Woof earned this write-up for a reason that has nothing to do with its claimed numbers: the release ships no runtime. No ONNX graph, no WASM engine, no inference library. Numpy arrays and a ~380-line Python reference, published on Underdog’s own Hugging Face org (ConwayResearch/Underdog-Woof).

We ported it anyway — first to a typed-array TypeScript runtime, then to 22 hand-written WGSL compute kernels over raw WebGPU. Both run in the Woof demo on TTSLab today, and the two agree to a Pearson correlation of 0.9999998 in the browser. What follows is our appreciation of what Underdog built, and an honest account of what meeting it in the browser took.

The release, in numbers

Woof comes in two published configurations (announcement, landing page): a one-voice model, isla, at 30.7M parameters and 31.7 MB, and a two-voice model — juno and mira, the pair we ship — at 38.2M parameters and 40.6 MB. Underdog’s announcement claims a word error rate of 1.42–2.06% on a set of hard sentences — per Underdog’s evals, which we have not independently reproduced — and 13 seconds of speech synthesized in 0.73 seconds on a single CPU thread. TechCrunch wrote up the demo on November 6, the day before the release post.

For calibration: the two-voice 40.6 MB file is the second-smallest download in our catalog. Most of what we run is hundreds of megabytes; a few models top 2 GB. A TTS with claims like these, small enough to arrive before a coffee does, is rare enough that we read the repository twice.

The radical part: they shipped a spec

Most model releases ship a checkpoint and leave the plumbing to the ecosystem: ONNX exports, conversion scripts, runtime bindings, a README of version pins. Woof ships none of that. The Hugging Face repo is a set of numpy arrays and one ~380-line Python file — underdog_woof/runtime.py — that reads them and speaks.

We think this is the most underrated decision in the release. A readable reference implementation is a spec — an executable one. The char-level vocabulary, the RoPE transformer, the flow-head decode loop, the SEANet vocoder, the exact defaults for temperature and end-of-sequence gating: all of it sits in ~380 lines of numpy that fit in your head. There is no ambiguity about which operation goes where, because the operations are right there, in the order they run. Reverse-engineering an exported compute graph costs weeks before a single line of a port gets written; the reference saved us all of it.

Porting it: the reference doubles as an oracle

We ported runtime.py to TypeScript — plain typed-array loops, no native code — one stage at a time, and after each stage compared our intermediate tensors against the numpy original. Final agreement: correlation +1.00000000, at every stage.

That sentence is the best part of this story, because of everything it ruled out. Our port compiled cleanly under TypeScript strict mode, and four real bugs still made it through. The stage-by-stage oracle comparison caught every one:

  1. An MT19937 tempering shift. Woof’s decoder generates audio frame by frame from seeded Gaussian noise, drawn from a torch-compatible MT19937 stream. Our first implementation tempered the generator’s raw output with one bit-shift in the wrong place. The result was a smooth, plausible stream of noise — just not the specified one. Nothing downstream crashes; the audio merely is not the audio the seed promises. The correlation check caught it at the first stage that consumes noise.
  2. A RoPE key offset applied twice. The rotary position embedding added the key-side position offset two times, so attention scored every position against a skewed index. Subtly wrong attention degrades prosody; it never throws an exception.
  3. An in-place layernorm. NumPy is functional by default — operations return new arrays. Our typed-array port normalized its input in place, silently mutating a residual the decoder still needed two layers later. This is the classic hazard of porting immutable-by-default code to mutable arrays, and it corrupts output without touching any error path.
  4. A conv-transpose orientation. The SEANet vocoder’s transposed convolutions store their weights in a shape numpy reshapes without ceremony; our first read had the matrix orientation wrong. Shapes still lined up; the vocoder just reconstructed the wrong signal.

Every one of those four bugs passes a compiler and produces audio that sounds like speech. Only an exact oracle separates a working port from a correct one — and the reference being short enough to execute stage by stage is what made that oracle free. We could claim numerical exactness rather than “seems fine”, which is a claim most ports of weight files never get to make.

The second backend: 22 WGSL kernels, int8 in registers

Exactness is not speed. The typed-array path rendered our test sentence in about 6.1 seconds on our dev machine (Apple Silicon, measured 2026-11-08) — real, but not demo-snappy. So we built a second backend: 22 hand-written WGSL compute kernels driving raw WebGPU directly, no ONNX Runtime and no engine — the same reason Underdog skipped a runtime on the CPU side. We are not the first to make this choice in front of this model: Underdog’s own browser demo bundle runs 19 WGSL compute kernels, and we cite that work as the lineage for our kernel decomposition — their structure, our own WGSL and TypeScript throughout.

Two decisions carry the backend. First, weights stay int8-packed on the GPU and are dequantized in registers inside the matmul and convolution kernels. Dequantizing the 40 MB file into fp32 buffers would transiently cost roughly 250 MB of JavaScript heap — comfortably into the range where iOS Safari hard-kills a tab with no error (our limits report has the numbers). Packed, peak heap stays at about the download size. On a phone, that decision is the difference between the model running and not running.

Second, the decode loop steps frames in small batches with a single end-of-sequence readback per batch, and positions travel through a small storage buffer so every dispatch plan is built once and cached. Per batch, only noise and positions cross the CPU/GPU boundary.

Verified in the browser on 2026-11-08: same sentence, same voice, same seed through both backends — Pearson correlation 0.9999998 (bit-exactness across backends is not expected; floating-point accumulation order differs), identical frame counts, and about 0.19 seconds of GPU time against about 6.1 seconds on the typed-array path — roughly 31x. Those are our numbers: one sentence, one Apple Silicon dev machine. They are a port-verification result, not a benchmark of Underdog’s model, and your hardware will differ.

Determinism you can put in a link

Both backends draw their noise from the same torch-compatible MT19937 stream, generated on the CPU and uploaded to the GPU. Same text, same voice, same seed, either backend: identical audio. On a demo site that is a feature, not a detail — “can you render that again?” becomes a URL that renders exactly that again. Deterministic generation is one of the quiet benefits of small on-device models, and it only holds because the random stream is part of the published spec rather than a hidden implementation detail.

What we did not port

Three gaps, all deliberate:

  • The text normalizer. Underdog’s normalize.py is roughly 48 KB of rules that expand numbers, dates and prices into spoken words, and the model was trained on normalized text. We did not port it, so our demo asks for spelled-out text (“twenty-five dollars”, not “$25”); digits may be read oddly. It is the main quality gap between our port and the reference experience.
  • Streaming. We render whole text pieces and concatenate; the reference’s streaming modes are not implemented.
  • Take-retry. The reference detects stalled takes and retries them; we render a single take per piece.

None of these are mysteries. Because the spec exists, each is a well-scoped to-do with the answer already written down in Python. That is what a shipped reference does to ambiguity.

The license, stated plainly

Woof is not open-licensed in the MIT sense, and anyone building on it should know exactly what applies. The Hugging Face repo — weights and reference code — carries the Underdog Non-Commercial License 1.0. Underdog’s landing page names CC BY-NC 4.0 instead. We are not lawyers and offer no resolution between the two documents; we note both as published and build to the stricter reading. What both agree on: free for non-commercial use with credit, commercial use by arrangement — license@conway.tech.TTSLab is a non-commercial lab, and the Woof model page carries the required “Voice by Underdog Woof” credit.

Hear it

Woof runs in your browser at ttslab.dev/models/woof-tts — the typed-array path everywhere, the WGSL path wherever your browser offers WebGPU. For the platform context this port lives in — what a browser tab can and cannot do for on-device speech — read the limits report, and for what the timing numbers above do and do not mean, the benchmark page explains why we do not publish leaderboard claims from a single machine.

Why we appreciate this

It would have been easier for Underdog to ship an ONNX export and a pip package. Instead they shipped a model small enough to be everywhere and a reference readable enough to be rebuilt anywhere — including inside a browser tab, with no runtime vendor in the loop. The reference made our port exact instead of approximate; the small size made it loadable instead of aspirational; the determinism made it demoable; the license made our obligations a paragraph instead of a debate.

Small models, readable references, on-device inference: we built a whole site on that bet, and it is good to see a model lab bet the same way. We are fans of what they built — and we did the engineering to meet it in the browser, which is, we think, the highest form of appreciation available to a lab like ours.