Skip to content

Feat/tts benchmark - #281

Merged
robdmac merged 69 commits into
mainfrom
feat/tts-benchmark
Aug 21, 2026
Merged

Feat/tts benchmark#281
robdmac merged 69 commits into
mainfrom
feat/tts-benchmark

Conversation

@robdmac

@robdmac robdmac commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

No description provided.

robdmac and others added 30 commits August 14, 2026 09:39
Eighteen TTS configurations speaking one 84-phrase corpus, transcribed back by
Whisper so word error rate measures intelligibility end to end. Every row is
playable, which is the point: word error is blind to naturalness and
expressiveness, the axes the LM-backed engines are actually sold on, so the
table needs an ear alongside it.

Tabs are real links to each benchmark's own URL rather than client-side state,
so both stay deep-linkable, keep their own social card, and render exactly one
post per page, which is also what keeps heading ids unique. /benchmarks now
shows the default benchmark instead of stacking every one into a single
document.

Playback goes through Web Audio rather than an <audio> element, following the
original export. canplaythrough is a heuristic about download rate, so playback
could start mid-decode and swallow the first word, which for a speech benchmark
is disqualifying; decodeAudioData resolves only once the whole clip is PCM in
memory. A silent buffer on first interaction wakes the macOS output device,
which otherwise eats the first couple of hundred ms.

Data is extracted from the export's own table rather than results.txt: it
carries eight more columns and precomputed sort keys. A first extraction pass
silently dropped the four NeuTTS rows because they use <tr class="neu"> for the
shaded band, so row count is asserted against the 18 in results.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The export uses lowercase run ids (nt-2e-q4-metal, kittentts, speecht5), which
are fine as identifiers but wrong as the thing a reader sees. Adds a display
name per row and uses it in the table, the play button labels and the prose:
NeuTTS-2E, KittenTTS, Qwen3-TTS, Chatterbox, Kokoro, VibeVoice, StyleTTS2,
MeloTTS, CosyVoice3, CSM, SpeechT5, Bark, Piper.

Quantisation and device suffixes are preserved verbatim after the base name, so
NeuTTS-2E-q4-metal still says which artefact it is. The raw id is kept as the
row key and the audio filename, so nothing about the data or the samples moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Word error rate is blind to naturalness and expressiveness, which is what the
LM-backed engines are actually sold on, so the table needs a human axis. This
is the backend for it.

GET /tts/ballot issues a blind four-item comparison set. The set is chosen
server-side so a client cannot nominate what it is voting on, and the anchor is
never returned so the attention check is not detectable. POST /tts/ballot
records a ranking, requires it to be a permutation of exactly the items issued,
and marks the ballot rejected rather than deleting it when the seeded weak
engine is placed first, so the rejection rate stays auditable.

GET /tts/scores aggregates with Bradley-Terry rather than mean rank: engines do
not all face the same opponents, and mean rank rewards whoever drew weak
company. Verified against synthetic noisy ballots with a known ground truth,
where it recovers the exact ordering.

Ratings map to 0-100 through a logistic of log-strength. A linear map is
unbounded and produced ratings above 130 and below zero on a well-separated
field, which reads as broken in a table column; the logistic cannot leave the
range and preserves the ordering.

Nothing is shown until a configuration has 8 ballots, so early noise never
renders as fact.

The table is defined in the SCHEMA constant that initializeDatabase executes,
with a comment recording why: blog_subscribers was appended to a conditional
migration function that returns early on any migrated database, so it can never
be created by /init-db.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Takes the refreshed export: adds chatterbox-turbo and fastpitch, and restricts
the table to configurations at or under 2.0x RTF. A model that cannot keep up
with its own speech is not a candidate for reading anything aloud as it
happens, which is what this benchmark is for. Seven configurations fall out:
chatterbox and its q4/q8 builds, both NeuTTS-2E fp32 builds, csm and bark.

Note the two sources disagree slightly, and the HTML is the newer one:
chatterbox-q4 is 1.97x in results.txt but 2.03x in benchmark.html, so it misses
the cut by 0.03 on the numbers actually used.

The prose is rewritten around the surviving field rather than left pointing at
engines the filter removed. Chatterbox-turbo is now the headline arrival: 4%
word error at 0.98x, the first LM-backed engine here to match Piper's accuracy
and still keep up with its own speech. The quantization argument is recast,
since every NeuTTS-2E build that survives the filter is quantized and the fp32
builds are excluded outright.

Also fixes an entity-decoding bug that rendered the not-yet-scored PESQ marker
as a literal &middot;, by decoding with a real HTML entity decoder rather than
a hand-rolled replace list.

The ballot pool is narrowed to the 13 surviving configurations. This weakens
the attention check: bark at 55% word error was unmistakably bad to any
listener but runs at 4.26x and is now excluded, leaving speecht5 as the only
anchor. Recorded in the handler so the limitation is not silently inherited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Chatterbox-q4 measures 2.03x in the export and was falling out by 0.03. The
cutoff moves to 2.1x, which keeps 14 of 20 configurations and drops six:
chatterbox and its q8 build, both NeuTTS-2E fp32 builds, csm and bark.

The article records why the line sits where it does, and flags that q4 is the
one row here that is only nominally real time: RTF counts silence, and against
speech alone that configuration is 2.59x.

Its arrival also sharpens the quantization argument, which now runs across a
single family: the full Chatterbox build is 4.41x and excluded, q4 scrapes in
at 2.03x, and turbo reaches 0.98x while improving on both.

Ballot pool widened to the same 14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The two NeuTTS-2E Q4 rows differed by device, not by weights. Device
changes how fast the same model runs, not how well it speaks, so CPU and
Metal are now one row: word error is the mean of both builds over 336
samples, and speed, size and memory are Metal's. The case for treating
the gap as noise is that the stronger transcriber puts the two 0.09
points apart and only the weaker one separates them at all. PESQ and
lead-in stay unpooled because both are scored on a single clip, and only
Metal's is published.

Display names now come from a variant map, so "nt-2e-q4-metal" reads
"NeuTTS-2E Q4 Metal" and "chatterbox-q4" reads "Chatterbox Q4".

The table also gets the width it needs. The article column is sized for
prose and cut roughly half the columns off, so the figure now measures
the empty gutter to its right and extends into it, up to the ~1756px the
full table wants. Three things this took, each of which failed on its
own:

  - Not the 50%/50vw negative-margin trick. It centres on the viewport,
    but this column is pushed right by the TOC sidebar, so the figure
    hung 83-288px off the right edge.
  - Not an explicit width either. A fixed width raises the article grid
    track's min-content size, which grows the whole two-column layout;
    the figure's own box fit fine while the page scrolled sideways.
    A negative right margin buys the same pixels and lowers the
    intrinsic contribution instead of raising it.
  - overflow-x: clip on the figure. Without it the page still scrolled a
    constant 1288px at every viewport: the inner scroll container clips
    what you see, but the track kept sizing itself to the table
    unclipped. `clip` rather than `hidden` so the vertical axis is not
    forced to scroll too.

Verified at 430/800/1000/1280/1440/1600/1920/2200px: no horizontal page
scroll at any width, and the table gets 1280px at 1920 where it had 960.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Word error rate saturates once speech is merely intelligible, so the
table cannot separate the engines that are actually sold on naturalness
— which is most of the expensive ones. The Human column is that missing
axis, and it is the one number here not measured on my machine: readers
rank four blind clips, the control plane aggregates the pairwise
outcomes with Bradley-Terry, and the column updates live.

The dialog opens on the reader's first play, which is the moment they
have opted into listening and the one point where asking is not an
interruption. Asked once per browser, skippable, and re-openable from
the caption. Three things it is strict about, because the data is
worthless otherwise: clips are blind until the ranking is submitted;
reordering is buttons rather than drag, which is unusable on touch and
absent for keyboard users; and submit stays disabled until all four have
played to the end.

Verified against a local D1 with 240 simulated ballots, of which 3 were
junk voters who ranked the seeded weak engine first: all 3 were rejected
by the anchor check, and the aggregator recovered the planted quality
order exactly, with every rating inside 0-100.

Playback moves into useSamplePlayer so the table and the dialog share one
AudioContext, one buffer cache, and one "currently sounding" clip.

Three bugs found and fixed while testing this:

  - Clip letters were derived from list position, so reordering renamed
    every clip mid-ranking and moved the "already played" tick onto
    different audio. Letters are now fixed when the ballot is issued.
  - The row the reader originally clicked never played after the dialog
    closed: starting it in the close handler raced the dialog's own
    teardown, which stops whatever is sounding. It now waits for unmount.
  - Chart fences render a React component but stayed wrapped in the
    markdown <pre>, so every chart inherited white-space: pre. The TTS
    caption became one 2643px line running off the page. Both benchmark
    pages now unwrap a chart fence's <pre>; this also quietly fixes the
    two agent-skills charts.

The run caption claimed Bark and full Chatterbox were "still on one
pass", but the real-time filter drops both. Rewritten against the Passed
column: two passes for autoregressive, one for feed-forward, one for
Chatterbox Turbo, and 336 for the pooled NeuTTS device builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The article's headline claim — that nothing beats Piper on all three
axes at once — was only ever in the prose. The chart puts every
real-time engine on compute-per-phrase against word error, sizes each
marker by disk, and lets the reader see Piper alone in the corner with
the expensive LM-backed cluster off to the right buying no
intelligibility for twenty to fifty times the compute.

Two axis choices worth stating, both argued in the article:

  - x is compute per phrase, not the conventional RTF. RTF is compute
    per second of audio, so it flatters any engine that pads with
    silence; every engine here speaks the same 84 phrases, so per-phrase
    compute compares directly and cannot be gamed.
  - y is WER base rather than the stronger WER med, only because
    Chatterbox Turbo and FastPitch have no medium-model score and
    dropping the headline arrival from the headline chart would be
    perverse. base.en understates good engines, so the real spread is
    wider than drawn.

Colour follows the architecture class in a fixed order, so isolating a
class never repaints the ones that remain. The skill's palette validator
was not on disk, so the six checks were reimplemented and run rather
than eyeballed: the three hues separate by at least 14.8 in OKLab under
the worst of protan/deutan/tritan against a floor of 8, all clear the
0.45-0.85 lightness band and the chroma floor, and every hue is at least
4.3:1 on the chart surface. Two candidate third hues were rejected on
those numbers before this one passed with no warnings.

The social card is generated from the live chart by scripts/build-og-card.mjs
rather than drawn separately, so it cannot drift from the data — re-run
it when a run lands. It scales the figure to fit 1200x630 instead of
cropping, which in the first attempt lost the title off the top and the
x-axis off the bottom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Word error is the wrong y-axis for the question the chart was asking. It
saturates once speech is merely intelligible, so it cannot separate
engines that are all comprehensible and differ only in how they sound;
two of the thirteen have no medium-model score, so the stronger
transcriber cannot be used without dropping them; and the article
already spends a section warning that base.en understates good engines
more than bad ones. Ranking engines on a measure the page tells you not
to trust is not a chart worth publishing.

So the chart now plots reader preference against compute per phrase, and
waits: it renders nothing at all until at least six engines have a human
rating, then appears on its own with no redeploy. It sits after the
table rather than before it, since it is now downstream of the ballots
the table collects.

It carries no headline claim, deliberately. Whether compute buys
preference is precisely what is being measured, and asserting the finding
before any ballots exist would be inventing it. The title says what the
axes are; sharpen it once the shape is known.

The table now opens sorted by compute per phrase, cheapest first, for the
same reason word error is a poor way to meet it.

Verified both states against a local D1: empty, the page renders one
figure and no empty axis box; seeded with 240 ballots, the chart appears
below the table with all thirteen engines and every label inside the plot
box. That last part needed a fix — labels sit above their marker, which
puts the best-rated engine's name through the top border, so labels near
the ceiling now flip underneath.

The social card is dropped for now rather than left showing a chart the
page no longer has. scripts/build-og-card.mjs still generates it; re-run
it against the preference chart once there are enough ballots to draw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The index was a landing page that listed one thing and then asked you to
click again. /benchmarks now redirects to the default benchmark, and the
tabs on each benchmark do the navigating.

The redirect is temporary (307), unlike the /labs ones. /benchmarks is
the URL people share and the one in the site nav, and which benchmark is
default will change as more land; a 308 would be cached by browsers and
CDNs and strand readers on whichever benchmark happened to be first
today.

Two consequences of the change, both handled:

  - /labs pointed at /benchmarks, which would now bounce again. It goes
    straight to the default instead, so a link someone else published
    still costs one hop. Verified: /benchmarks, /labs and the old Labs
    post URL are all num_redirects=1, and /labs/og-do-skills.png still
    resolves for social cards already cached against it.
  - Every benchmark had an "All benchmarks" back link to a page that no
    longer exists. It either did nothing or silently moved you to a
    different benchmark, so it is gone; the tabs sit in its place.

The 286-line index page is deleted rather than left unreachable, along
with DEFAULT_BENCHMARK, whose only consumer it was. The default now lives
in one place: the first entry of BENCHMARK_TABS, mirrored in next.config
with a comment on each side, since next.config cannot import it.

Checked the thing a redirect most easily breaks: a crawler following
/benchmarks reads the destination's tags, so the shared link still
previews as a chart (og-agent-skills.png, confirmed 200 and image/png)
rather than falling back to the generic site card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Passed was the denominator behind the word error rates (83/84, 162/168),
which is methodology rather than a result — it never distinguished one
engine from another, and the sample counts it carried are already stated
in the caption. Frame rate was blank for nine of the thirteen engines,
because it only means anything for the token-based ones, so it was mostly
a column of dashes.

Sixteen data columns now instead of eighteen, so the table needs 1662px
rather than 1756 and the breakout cap comes down to match; leaving it at
1760 would have padded dead space onto the right on wide screens.

Nothing referenced either column by index — the chart looks its columns
up by name — and the header/body cell counts were checked to stay aligned
after the splice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The markdown is the source but the pages render from
src/data/benchmarks-posts.json, which scripts/build-content.mjs
generates, because Workers have no runtime fs. The rewritten intro and
description were edited in the markdown without that regenerating, so
the live page and the social description were still the old text.

Verified after rebuilding: the new opening renders and the old one is
gone from the HTML, and <meta name="description"> matches the frontmatter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The table listed only what could keep up with real time, which meant the
answer to "how far off is it" was missing and the exclusion could not be
checked. Every configuration measured is now listed, and a Real time only
control hides the nine above 2.1x for readers who only care about what
can be spoken live. Default is everything.

Fourteen configurations arrive with this export: the full Chatterbox and
its q8 build, both NeuTTS-2E fp32 builds and Q4 on CPU, CosyVoice3 RL,
VibeVoice 1.5B, F5-TTS, OmniVoice, BananaMind TTS, Zonos, CSM, Parler-TTS
and Bark. The export also adds a Licence column, which is kept - for open
weights it is often the deciding column, and it flags the four that are
restricted rather than permissive.

The import is now a script rather than done by hand, because this is the
second export and hand-transcribing 27 rows of 19 columns is how rows go
missing. It parses benchmark.html, which unlike results.txt carries the
sort keys, tone classes and row grouping, and it asserts its row count
against the README's own prose count. Two parsing traps are handled
explicitly, both of which have silently eaten data here before:
attributes are read as a set, since `title` can precede `data-sort` and a
positional pattern then yields empty sort keys for exactly the numeric
columns; and rows match `<tr...>`, since the grouped NeuTTS rows carry a
class and a bare `<tr>` drops all four. It also prunes audio for
configurations that are no longer listed.

My earlier hand-pooling of the NeuTTS Q4 device builds is dropped: this
run measures them separately and afresh (9% on CPU, 11% on Metal), so
pooling would now mean overwriting measured values with my own
arithmetic. The four-row band is shaded instead, which is what it is for.

Ballots draw from all 27, and Bark returns as an attention check. It was
excluded before only because the table was real-time-only, and at 55%
word error it is the engine nobody listening could rank first in good
faith. VibeVoice 1.5B joins it at 37% and the lowest PESQ measured, so
there are two anchors rather than one - with a single known-bad engine a
determined submitter can just learn never to rank it first.

Three claims in the article were true only of the 13-row table and are
corrected against the new numbers: SpeechT5's PESQ is no longer the only
score under 3 (eight are), it is no longer the worst engine (Bark,
Parler-TTS and VibeVoice 1.5B are worse), and Piper is no longer smallest
on disk (BananaMind TTS is 38 MB against its 60 MB) - though nothing
still beats it on either accuracy or compute per phrase.

Verified: filter toggles 27 <-> 18 with a max shown RTF of 2.03, sort and
the shaded band survive it, all 27 clips resolve, and no viewport from
430 to 1920px scrolls the page sideways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
A third click on any column header dropped the table to "unsorted",
which rendered the export's own row order. That is neither the order the
reader arrived at nor one anything on the page explains, so the table
silently rearranged itself into something arbitrary and there was no way
back to the default short of a reload.

The cycle is now asc -> desc -> default (compute per phrase, fastest
first), so every state the reader can reach is one the page accounts for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Four engines arrive: MMS-TTS, Dots-TTS, and the Tada 1B/3B pair. The
comparison is 31 rows, of which 20 keep up with real time.

Two things the importer caught that would otherwise have shipped.

The row-count assertion fired: the README counts 33 configurations but
the table holds 31. That turned out to be correct rather than a parse
failure - kugelaudio failed outright (0 of 34 phrases, no audio) and xtts
produced no PESQ and no sample, so the exporter lists their measurements
without putting them in the comparison. The check was comparing a parse
against a prose count of everything *measured*, which cannot tell "the
exporter left it out" from "my pattern missed it", so it is replaced by
two checks that can: the matched rows must equal the count of <tr> tags
in the tbody, and every configuration parsed must appear in results.txt.
The reverse direction is now reported rather than fatal.

The real find: the export gives unmeasured cells sentinel sort keys, -1
for PESQ and Params, 99 for WER med and Lead-in, so they pile up at one
end of a sort. That is fine in a static table and wrong in a sortable
one - sorting by PESQ ranked the three engines that have no PESQ as the
worst-sounding in the table, above VibeVoice 1.5B, and thirteen with no
medium-model score sorted as the least accurate. 53 cells across seven
columns were affected. The key is now blanked whenever the cell reads as
missing, so the table sinks them at both ends, which is what "not
measured" should do. Licence "?" is deliberately left sortable: there it
is a real category meaning the card could not be verified.

Verified: PESQ ascending now leads with VibeVoice 1.5B at 1.60 and
descending with Piper at 4.10, with the three unmeasured rows last in
both directions; the filter toggles 31 <-> 20.

Ballots draw from all 31. Article counts corrected: thirty-one
configurations, eleven above the cutoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Libs is a packaging detail already folded into Disk. Lead-in is measured
on a single clip so it was never a mean, and in a table this wide it was
costing more room than it earned - the caveat it existed to explain (that
Whisper transcribes leading silence as a word, so padding inflates word
error) moves into Scoring, where it applies to every row rather than one
column. "Total disk" becomes "Disk" and NeuTTS's licence sentence becomes
"NeuTTS, <$5m", which was the widest cell in its column.

Fifteen data columns now, and the table measures 1603px rather than 1806,
so the breakout cap comes down to match.

The rename broke the page, which is worth recording. The preference chart
resolves its columns by name, and COL["Total disk"] silently became
undefined, indexed into nothing and threw at module load - taking the
whole route to a 500. tsc cannot catch it because the lookup is typed
Record<string, number>, so any string compiles. Both components now
resolve columns through a checked helper that throws naming the missing
column and listing what the run actually has, instead of failing several
frames later with a stack pointing at React.

The licence rewrite uses a replacement function rather than the string
"<$5m": as a string, "$5" is a capture-group reference, and would quietly
start substituting the day that pattern gains a fifth group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Every row is now plain. The article paragraph that pointed at "the
shaded band" refers to the four NeuTTS-2E rows instead, since there is
no longer a band to see.

The group field stays on the row data - the export still marks those
rows, and dropping the highlight is a presentation choice, not a reason
to discard what the run recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Samples were silent on iPhone in both Chrome and Firefox, which is one
bug rather than two: every iOS browser is WebKit underneath. Dev serves
the files correctly (200, audio/mpeg, right sizes), so this was playback,
not delivery.

iOS puts Web Audio in the "ambient" audio session, which the hardware
ring/silent switch mutes. <audio> elements and native apps sit in
"playback" and ignore it - which is exactly why Spotify stayed audible
while the page was not, and why that comparison does not rule the switch
out. The context now declares navigator.audioSession.type = "playback",
the same category a music app uses. Safari 16.4+, ignored elsewhere.

Second fix, same symptom: playback goes prime() -> await decode() ->
start(), and decode() is a network fetch, so by the time start() runs the
user gesture is long over. A context still suspended at that point plays
nothing at all and the source is spent. The context is now re-asserted
immediately before start, and if it will not run the attempt stops rather
than consuming the source on silence.

Both failures previously looked identical to a dead button, and a phone
has no console to check, so the state is now visible: the table and the
ranking dialog say that playback was blocked and to check the ring/silent
switch. The dialog needed it most - it opens before the table, so a
blocked context there is four dead buttons and a submit that can never
enable, with nothing on screen explaining why.

Desktop playback re-verified unchanged. The iOS half cannot be verified
from here; if it is still silent, the on-screen message now distinguishes
"blocked" from "failed to load" rather than leaving it a guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Two things the dialog did not communicate: that a reorder had taken
effect, and how to reorder in the first place.

The list used to teleport into its new order, so on a phone the press
and the result were impossible to connect. Rows now animate between
positions with a FLIP: React reorders them, then each row is put back
where it was with a transform and transitioned to zero. Respects
prefers-reduced-motion, where the swap stays instant. The move is also
announced in a live region, since an animation says nothing to a screen
reader.

"Put them in order, best first" described the goal but never the
mechanism. The instructions now name the arrows and say what they do,
and promise the order can be checked before anything is sent - which it
now can: submitting opens a confirmation listing the ranking best first,
with Go back and Confirm. Clips stay playable there, because reviewing
an order you cannot re-hear is not much of a review, and they stay blind,
because revealing the engines before the ballot is recorded is exactly
what the blind ranking exists to prevent.

Verified: mid-swap the moved row carries a 60px inverted transform with
transition none and settles to none; under reduced motion there is no
transform at all; Go back preserves the reordered list; and the full
path still records a ballot and reveals the names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The dialog handed you the clips already in an order, so the cheapest
path through it was play 1, 2, 3, 4 and press submit — which records the
order the server happened to shuffle them into, not an opinion. The slots
now start empty. There is no default to accept: a ballot is four
deliberate placements or it does not exist.

Four clips sit in a tray above four slots labelled BEST, 2nd, 3rd,
WORST. The ends are named rather than numbered because "BEST" states the
task where "1st" only states a position.

Placing works three ways, because any single way excludes someone. Drag
is built on pointer events and pointer capture, NOT HTML5 drag and drop,
which does not fire on iOS at all — and iOS is where this was reported.
Tapping a clip then a slot costs the same two taps and is what actually
works with assistive touch. Keyboard is focus, Enter, focus, Enter.

Three bugs found while building it, all of which would have shipped:

  - Binding move/up in an effect keyed on drag state attached the
    listeners one render after pointerdown, so a quick tap — the common
    case on a phone — sent its pointerup before anything was listening
    and the press did nothing. Pointer capture on the chip removes the
    gap entirely.
  - "Has it moved far enough to be a drag" measured from the previous
    pointer position rather than the press, so a slow drag never crossed
    the threshold and stayed a tap forever.
  - A tap on a placed clip both selected it and removed it, the two
    cancelling out. A tap now means one thing per context: pick up in the
    tray, put back in a slot.

On a phone the copy pushed the WORST slot off screen, which defeats the
point of naming the ends, so the text is shorter and the boxes tighter:
BEST through WORST and both buttons now fit an iPhone SE without
scrolling. The tray is a grid, not wrapping flex — flex put three clips
on one row and the fourth alone below, which reads as a mistake. Four
across on desktop, 2x2 on phones.

Verified on desktop mouse, simulated touch, and a CDP touch drag: all
three placement paths land, the ghost follows the finger, and submit
stays shut until every clip has both been played and placed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
"What the numbers say" was the interpretation — which engine wins, what
quantization buys, which four are not really in the running. Thirty-one
rows that can each be played and re-sorted make that case without being
told it, and the interpretation was also the part that went stale fastest:
three of its claims had already had to be rewritten across the last two
exports because they were only true of a smaller table.

The intro line naming Piper and SpeechT5 as the ends of the scale went
with it, being the same interpretation in miniature; it now just says the
rows can be played.

What stays is everything that says how the numbers were produced or how
to read them: the column guide, which exists because RTF and WER base
actively mislead taken at face value, and Methodology with its exclusions
and limitations.

TOC and anchors re-checked: five headings, no dead links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Thirty-four configurations compared, twenty-two of them real time. New
arrivals: XTTS, which the previous export measured but could not show,
and the NeuTTS-2E Q8 pair.

Every one of the 32 clips is new audio under its old filename. That is
the case worth guarding: the bytes change, the URL does not, and browsers
and the CDN go on serving last week's audio against this week's numbers -
wrong in the way that is hardest to notice, because everything still
plays. Each sample URL now carries an 8-character hash of its own
contents, so changed audio is a different URL while unchanged audio keeps
its own and stays cached. The importer reports what it replaced, and
re-running it is a no-op, which is how the 32-of-32 replacement was
confirmed rather than assumed.

Two bugs found doing it, both of which defeated the point:

  - The run JSON was serialised before the copy loop that appends the
    hashes, so the audio was correctly replaced and every URL in the data
    still pointed at the unversioned name. The write moved after the copy.
  - The Q8 pair has no published clip, and the ranking dialog drops what
    it cannot play - but submitBallot requires the ranking to be a
    permutation of the items it issued, so seeding a clip-less
    configuration produces a ballot the reader can complete and the
    server is then obliged to reject. The ballot pool is now built from
    configurations that actually have audio: 32 of the 34.

Verified in the browser: 34 rows, the two clip-less rows have their play
control disabled rather than failing on click, and the audio that loads
is 200 on a versioned URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The zip was re-cut with the two NeuTTS-2E Q8 clips that were missing, so
every row in the table is playable and the ballot pool is the full 34
rather than the 32 that had audio.

Only those two files changed; the importer's own comparison said so and a
by-filename diff of the directory agreed. Worth noting the first check I
wrote to confirm that was wrong, not the data: it piped two md5 lists
sorted with `sort -k2` into `comm`, which compares whole lines and so
needs whole-line ordering, and it duly reported nine changed files that
had not changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
WER med is dropped and WER base becomes simply WER. With one column there
is nothing to qualify, and base.en is the right one to keep: it scored
every configuration, where medium.en did not finish for sixteen of the
thirty-four.

The column guide is rewritten to match. It keeps the part that matters -
base.en is the weaker recogniser and hallucinates words onto trailing
silence, which understates good engines more than bad ones, so the real
spread between engines is wider than the column shows. That caveat was
the point of the original entry and it survives the column going; what
goes is the comparison between two columns, only one of which is now
there.

Fourteen data columns, table down to 1530px, breakout cap follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The NeuTTS band was one backbone measured on two devices at three
precisions, six rows of a thirty-four row table. The CPU builds go, and
with them gone the remaining rows need no device suffix: NeuTTS-2E FP32,
Q4 and Q8. Thirty-one rows, twenty of them real time.

Done in the importer rather than by hand, so the next export inherits it,
and the three orphaned clips were pruned from public/ automatically.

The methodology paragraph that existed to explain a four-row band is
rewritten for a three-row one, and both counts it depends on were
recomputed against what is actually shown: eleven configurations above
the cutoff, and fifteen of thirty-one with no medium.en score.

That paragraph originally ended with "quantizing is worth roughly four
times what the device is", which I then checked and it is not: on the GPU
path quantizing fp32 to Q4 or Q8 is a 5.0-5.4x speed-up, while moving off
the CPU is 1.31x once quantized and 0.99x - nothing - at fp32. The
sentence now says that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The export re-cut the audio for FastPitch, BananaMind TTS, Parler-TTS and
Bark. Exactly those four files changed and nothing else, confirmed by
diffing the directory by filename before and after; their URL
fingerprints rotated with them, so returning readers get the new audio
rather than whatever their browser cached.

The chart is back, plotting word error against compute per phrase. It is
a new component rather than the old one revived, because two things have
changed since: the column is now called WER, and the table is no longer
real-time-only.

That second one forced the axis. Compute per phrase now runs from Piper
at 0.12s to Dots-TTS at 41.7s, a factor of 338, and on a linear axis
seventeen of the thirty-one engines pile into the first eighth of the
width. So x is logarithmic, with decade-anchored ticks labelled in
seconds and the log scale called out in the caption - a log axis that is
not announced is a way of understating differences without saying so.

The run also has a fourth Class value, "?", for the one engine whose
architecture could not be established. It is drawn in muted ink rather
than a fourth hue: it is a missing value, not a category, and a colour
alongside the three real ones would say otherwise.

Verified: 31 markers, none outside the plot box, no label overlapping a
marker after right-hand labels were lifted clear of the dense band.

The preference chart stays in place below it and still renders nothing
until enough engines have a human rating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Ten gaps filled: PESQ for NeuTTS-2E Q8 and Tada 1B, Peak RSS for eight
including Chatterbox Turbo, MMS-TTS and VibeVoice 1.5B. Nothing else
moved - no existing value changed, none was lost, and no audio differs,
all checked by diffing the two columns and the sample directory against
snapshots taken before the import rather than trusting the run to be
additive.

Still blank: PESQ for Dots-TTS and Tada 3B, Peak RSS for seven, mostly
the partial runs. They sort to the bottom in both directions, re-verified
here, because the export gives unmeasured cells sentinel keys that would
otherwise rank them as the best or worst in the table.

Row count and both counts the article quotes are unchanged: 31
configurations, 11 above the cutoff, 15 without a medium.en score - the
last re-checked against the export rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Q8 gains Params (0.24B), Weights (645 MB) and a Class, which was the last
"?" in the run - so the chart's Unclassified legend entry retires itself
and the field is three architectures again.

This export was not purely additive, which is worth recording because
"missing values filled in" suggests it would be. Five existing values
were revised: Q8's Disk 1.4 -> 2.0 GB and Peak RSS 2.0 -> 1.5 GB, and two
Peak RSS figures that the previous export had only just filled -
BananaMind TTS 358 -> 543 MB and FastPitch 527 -> 397 MB. Caught by
diffing every cell against a snapshot rather than only the columns the
change was announced for.

Audio is untouched, all 31 rows still render, and the chart still plots
31 points.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Dropping onto an occupied slot sent the occupant back up to the tray. So
the most ordinary correction there is - deciding two clips are the wrong
way round - cost two moves and threw away work already done: drag one
over the other, watch the other fly to the top, place it again. The
occupant now takes the slot the dragged clip just left.

A clip dragged in from the tray still displaces the occupant to the tray,
because there is no slot to swap it into.

The swap turned out to be only half the bug. It worked immediately, and
was then partly undone a moment later: pointer capture sends the click
that follows a drag to the chip that was grabbed, which sits in the slot
dragged *away from*, so it bubbled to that slot and read as a tap - and a
tap on a placed clip means "put it back". The result looked exactly like
the eviction being fixed. The click a completed drag produces is now
swallowed, with the listener removed on a timer so it cannot eat a later
real one.

Verified with mouse and with a CDP touch drag: swapping across slots and
between adjacent ones leaves the tray empty, tapping a placed clip still
returns it to the tray, and a tray clip onto an occupied slot still
displaces as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
robdmac and others added 28 commits August 18, 2026 11:41
The cutoff is 2x rather than 2.1x. It was set at 2.1 to keep Chatterbox
Q4 in at 2.03x, which was always a line drawn around one row; at 2x
nothing sits awkwardly against it, since the slowest engine that keeps up
is OmniVoice at 1.51x and the fastest that does not is Chatterbox Q4 at
2.03x. Twenty-two configurations keep pace, twelve do not.

With everything listed, the table now rules a line at the cutoff when it
is sorted by RTF or compute per phrase - the two orderings where the two
groups are contiguous and a line is therefore a place rather than an
arbitrary row. Verified contiguous under both before relying on it: one
crossing each, not several.

The label says which side is which, because that flips with the sort
direction: ascending it reads "slower than real time below", descending
"real time below". No line under any other sort, where fast and slow
interleave, and none with the filter on, where there is nothing on the
far side to divide from.

One trap worth recording: the boundary test reads RTF from the *rendered*
columns, not the run's. Human is spliced in ahead of it, so the run's
index points one column to the left and the test would have compared the
wrong numbers while looking perfectly reasonable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The divider was drawn in the accent - the same orange the table uses for
an active play button and a pinned note - which on a full-width rule read
as an error about the rows beneath it. A change of pace is not a fault.

It is a band now rather than a rule, in the header's own surface with the
same border colour, so it belongs to the table's structure. The label is
muted ink at small caps, and the graphic is three descending blocks:
"the pace drops here" carried by shape rather than by colour, which also
survives being read by anyone who cannot separate the orange from the red
error text two columns over.

Wording follows: "below: slower than real time, over 2x", still flipping
with the sort direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
One cell moved: Dots-TTS's PESQ, 3.53 to 3.57. No row was added or
removed, no audio changed, and nothing was filled - PESQ had no blanks
left to fill, having been completed by the export before this one.

Recording that because the change was described as more PESQ filling in.
The three PESQ values this export does add belong to the NeuTTS CPU
builds, which the table stopped showing when the CPU rows were dropped;
they sit in the raw export rather than on the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Peak is the only kind of RSS reported here, so the word said nothing the
column did not already imply. The definition moves into the column guide,
which now opens "RSS is peak resident memory" - the detail is worth
keeping, just not in a header.

Sorting still reads the numeric key, so 246 MB through 8.4 GB order by
size rather than by the string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
… word

Two things, both real.

The empty row was .legal-content's `table { margin-bottom: 1.5rem }`
landing inside this component's own bordered wrapper - 24px of margin
drawn as if it were a row, between the last engine and the border. The
table sets margin-bottom: 0. The gap under it is 1px now rather than 25.

While there, the same stylesheet switches tables to display:block with
their own overflow-x under 640px, which would have given this a second
scroll container nested inside the one it already has. Pinned to
display:table; the wrapper still scrolls on a phone, 1097px of table in
340px of viewport, with no page-level scroll.

The band said "below: real time" when the sort was reversed. Accurate -
that is what was below it - but it changes the subject as well as the
direction, so the sentence has to be re-read after every sort. The
subject is fixed now and the direction moves: "below: slower than real
time" ascending, "above: slower than real time" descending. Checked in
all four states across both boundary sorts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
A button under the table invites another ranking, where a link buried in
the caption asked too quietly - the Human column only fills if people
rank, and the moment to ask is when someone has just finished reading the
table.

Two rules, both enforced in the control plane because the client cannot
be trusted to count its own votes:

  - Three ballots per voter, keyed on the same IP-and-user-agent hash the
    table already stores. Enough that a willing listener adds real signal,
    few enough that no single pair of ears can move a rating alone, which
    is the failure mode of an open unauthenticated vote.
  - Nothing already ranked comes back. A second opinion on the same clip
    from the same ears is not a second data point, and Bradley-Terry would
    count it as one. The anchor is drawn from unheard engines too,
    otherwise the attention check degrades into a memory test.

The cap is checked at submit as well as at issue. Issuing is cheap and
unauthenticated, so a client can hold several open ballots and submit
them all - verified: four hoarded ballots submit 200, 200, 200, 409.

Only submitted ballots count toward either rule. An issued-then-abandoned
one must not burn an attempt or retire four clips, or closing the dialog
would quietly cost the reader a vote.

Exhaustion comes back as a 200 with a reason rather than an error status,
so the dialog can say which wall was hit instead of showing a generic
failure. Verified end to end in the browser: three rankings, twelve
distinct engines, no repeat, then "that is your lot".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Both Hume models were listed "?", which is the export saying it could not
read a licence off the card - a statement about the card, not about the
model. Both cards do declare one: `license: llama3.2`, and Hume's Space
says plainly that "the model is licensed under the Llama 3.2 Community
License Agreement".

The cells link to each model repo's own LICENSE file rather than to the
Space that pointed me at it, on the same rule as the rest of the column:
link where the project states its licence, and prefer the weights' own
statement over anything about them. Both verified 200.

Overrides live in the importer with the evidence written next to them, so
the next export cannot silently revert them to "?" and so it is clear
they were established rather than assumed.

The override sets the sort key as well as the text. Left alone it would
have sorted under "?" while displaying "Llama 3.2 Community" - wrong in a
way nothing on screen would reveal.

Two rows are still genuinely unverified: Dots-TTS, whose repo declares
Apache-2.0 while the table says "?", and OmniVoice, which has no upstream
card at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Both are Apache-2.0, both verified at the source. OmniVoice gains a home
as well - k2-fsa/OmniVoice - so the only row still without a model link
is BananaMind TTS. No cell in the licence column says "?" any more.

Dots-TTS closes the loop on the mismatch the licence cross-check turned
up when these links were first added: its repo declared Apache-2.0 while
the table said unknown. The repo was right. Its URL also moves to
studio-dots-ai, which is where rednote-hilab had been redirecting, in
both the table and the vendor strip.

k2-fsa joins the vendor strip, which is nineteen organisations now, still
alphabetical and still two rows.

All 54 distinct model and licence URLs re-checked end to end: every one
returns 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
0.8B, April 2026, from arXiv:2604.00688 - "OmniVoice: Towards Omnilingual
Zero-Shot Text-to-Speech with Diffusion Language Models", submitted 1 Apr
2026. Checked the identifier resolves and the title and date match before
taking the numbers from it.

Both sort correctly rather than under the blank they replace: Released
sorts on YYYYMM and Params on millions, so text-only overrides would have
kept sorting as missing while displaying a value - wrong in a way the
page would not show. Verified in place: OmniVoice lands between 2026-02
and 2026-07 by date, and between 0.64B and 0.88B by size.

Its note said the licence was unverified, which the row now contradicts
two columns to its left. Rewritten to say where the size and date came
from instead.

The three licence overrides are folded into one CELL_OVERRIDES table
keyed by configuration and column, rather than growing a separate map per
column, with the evidence for each written beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
BananaMind TTS was the last row missing anything: 9.5M parameters and a
home at Banaxi-Tech/BananaMind-TTS-V2, whose card states both the size
and the apache-2.0 the table already carried. Every row in the table now
links to its model and to where that project states its licence, and
every licence cell is filled.

The licence links to the model repo's own LICENSE rather than the Space
README, which was the source offered - the repo has one, and the weights'
own statement beats anything written about them. Same rule the rest of
the column follows.

Banaxi-Tech joins the vendor strip. That took it to twenty and pushed it
onto a third row, so the chips are tighter: 11px of horizontal padding
rather than 14, 16px logos rather than 18, 6px gaps rather than 8. Ten
and ten from 1280px up, where before nineteen already needed three rows
at 1024. Nothing below 1024 changes shape - a phone still wraps to
whatever fits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
…data

Confirmed against the 13:40 export: both lose "(partial)". Dots-TTS
finished 80 of 84 phrases where it had managed 68 of 72, and Zonos 68 of
84 where it had managed 63 of 78. Parler-TTS is still partial at 10 of
17, so one row keeps the marker.

Finishing the corpus cost them time - the phrases they had been skipping
are the long ones. Dots-TTS goes 41.68s to 51.01s per phrase and 13.02x
to 14.22x; Zonos 31.60s to 34.78s and 10.59x to 11.07x. Word error is
unchanged to the nearest point in both cases.

That broke the chart, quietly. Its x axis ended at a hardcoded 50s, fitted
to the range of an earlier export, so Dots-TTS at 51.01s drew outside the
plot with nothing to indicate it. Both axes now derive from the data with
headroom, and the ticks are filtered to what the axis spans, so the next
slow arrival cannot walk off the edge. Verified: 34 markers, none outside
the plot box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
All five land as described: Chatterbox 441 MB to 3.5 GB, and four small
corrections to Chatterbox q8/q4 and CosyVoice3/RL. The big one reads as a
fix rather than drift - 441 MB was less than Chatterbox's own 3.0 GB of
weights, which was never physically possible, and 3.5 GB puts it where
its siblings sit (Turbo 1.7, q8 1.4, q4 1.2, against 980 MB to 3.0 GB of
weights).

The export also moved three Parler-TTS figures that were not in the list:
WER 39% to 42%, RTF 3.90x to 2.84x, and 31.23s to 25.29s per phrase. It
is not a re-measurement so much as a different sample - the run went from
10 of 17 phrases to 5 of 9, so a shorter partial over different material.
It is the one row still marked partial, and this is what that marker is
warning about.

Nothing else changed: no audio, no other row, no column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Three chips beside the real-time toggle: ar-lm, det-ff, st-ff, ordered by
how much of the table each accounts for. They are additive - none pressed
shows everything - so there is no "all" chip whose state has to be kept
consistent with the others, and clearing the last one returns to the
full table on its own.

They carry the chart's class colours, because filtering here and
isolating there are the same grouping and should not look like two
different schemes. The hover description is the same one the Class cells
give, so the codes are readable from the filter as well as from the row.

Filters combine: ar-lm plus det-ff is 29 rows, and adding real time only
takes it to 18. The count now derives from what is actually rendered
rather than from a precomputed real-time total, which only described one
of the filters and would have gone quietly wrong beside a second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
…mport

The table and the charts each had their own class control, so pressing
one did nothing to the other - which is why the filters read as being on
the chart and not the table. There is one selection now: filtering to
ar-lm in the table narrows the plot below it, and pressing a class in the
chart's legend filters the table.

They are separate ```chart fences with no common React parent to hang a
provider from, so the selection lives in a module-level store read through
useSyncExternalStore. Both components import it and see the same Set; no
provider, no shared ancestor. Rows are removed from the table and dimmed
rather than removed in the charts, because a scatter with points taken
out loses its scale.

Verified both directions: det-ff pressed in the table gives 7 rows and 7
lit markers with both chips showing pressed, ar-lm added from the chart's
legend gives 29, and clear from the table resets both.

Separately, the import now rewrites the control plane's CONFIGS list. It
has to match the table - a ballot offering a clip the page no longer
shows is one the server then refuses, and a row missing from the pool can
never be rated - and it was being regenerated by hand after every import,
which is precisely the step that gets forgotten. Proved by breaking the
pool to two stale entries and re-importing: back to 34, none stale, none
missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Readers reported that samples stop playing after a while on the page, on
both iOS and macOS Safari. WebKit suspends an AudioContext that has been
silent for a while, and interrupts one whose tab is backgrounded or whose
audio session another app takes. Coming back needs resume() from inside a
user gesture, and a gesture does not survive an await.

Both of our resume() calls were on the wrong side of that line. prime()
does resume, but latched primedRef on the first play and early-returned
forever after; ensureRunning() runs after the clip has been fetched and
decoded, so its resume has no activation behind it and Safari rejects it.
The first plays worked, then the context went down and every later click
was refused - which looked like a dead play button.

prime() now attempts a resume whenever the context is not running, and is
called synchronously at the top of the click so the activation is still
live. ensureRunning() awaits that attempt rather than starting a second,
gesture-less one. A statechange listener clears the playing state when the
context leaves "running", since a clip cut off that way never fires onended
and would otherwise leave the button stuck showing "stop"; a late onended
from the abandoned source no longer counts as heard. Returning to a
backgrounded tab also tries a resume off sticky activation, so the next
click is instant when it works and no worse when it does not.

Six tests around a fake context that enforces the gesture rule. The four
covering suspension and interruption fail against the previous player.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
…ays down

The previous commit made Safari worse, not better: samples stopped playing
there entirely while Chrome was fine. The cause is in the code it added.
ensureRunning() awaited the resume() promise that prime() had started, on
the reasoning that this was the attempt with a gesture behind it. But
WebKit does not settle a resume it will not grant - it leaves the promise
pending for the life of the page, where Chrome rejects it. So the await was
a deadlock: setLoading stayed set, the finally never ran, and the button sat
there forever. The deployed bundle showed it plainly:

  x = async () => { let e = f(); if (g.current) try { await g.current } catch {} ... }

So: no resume promise is awaited anywhere now. ensureRunning() asks again
and then waits on the "statechange" event with a 600ms cap, which WebKit
does report. Hover no longer asks for a resume at all - it has no
activation to spend, cannot be granted, and is what left those pending
promises lying around; it only wakes the output device, and the click does
the resuming.

Two fallbacks behind that, because a benchmark whose samples will not play
is not worth reading. If the context cannot be brought up, the clip plays
through an <audio> element instead: WebKit governs media elements far more
lightly, and the bytes are already local, so the clip still starts whole -
the reason Web Audio was picked in the first place. And if the context
claims to be running while its clock stands still, which is what an
interrupted audio session looks like from script, the clip is moved to the
element mid-play rather than left silently not sounding.

Verified in WebKit itself, via Playwright, against a local production
build - normal playback, a context that never runs with a resume that never
answers, a context whose clock is frozen, and suspend-then-play. The first
two paths use Web Audio and never touch the element; the last two switch
and are audible. Chromium unchanged throughout. 11 unit tests, including
one that hangs against the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Thirty-three configurations now. The importer removed the stale clip and
synced the ballot pool on its own; the rest is what a removal touches that
a table diff does not show.

Hugging Face leaves the vendor strip - Parler-TTS was the only model of
theirs measured, and the strip says these are the teams behind the models
in the table. Nineteen chips, still two rows. Their logo goes with it.

Two counts in the prose are recomputed rather than decremented, since
dropping a row moves them independently: medium.en now fails to finish for
eighteen of the thirty-three (it was nineteen of thirty-four - Parler was
one of the failures), and eleven configurations cannot keep up with their
own speech, down from twelve.
Safari still does not play on dev, and headless WebKit - the same engine,
driven by Playwright against that same deployed page - plays it fine, both
as a first-time reader and a returning one. So the difference is something
only a real Safari has: an audio device, a real audio session, the per-site
auto-play setting. I have guessed twice from here and been wrong twice, and
the third guess would cost another deploy to disprove.

So the page can now answer the question itself. A "No sound?" control under
the table opens a report - revision, context state, sample rate, whether the
audio clock is advancing, the audio session type, which path last played,
last error - and two probes that isolate the layers:

  - a test tone: a quarter-second sine started and stopped entirely inside
    the click, with no fetch, no decode and no buffer. If this is silent the
    browser is refusing Web Audio outright and nothing about our clip
    handling is implicated.
  - a clip through the element route, which answers to a different policy in
    WebKit than the context does.

Between them the four outcomes are distinguishable: both sound (the problem
is in the clip path), only the element sounds (Web Audio is refused), only
the tone sounds (fetch or decode), neither (the browser is refusing
everything). A phone has no console, so this had to be in the page.

Verified in WebKit against a local production build: panel opens, tone
reports the clock advancing 0.39s, the element probe reports position
0.35s, and the report fills in - audioSession moves from "auto" to
"playback" once the context exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
It was the last two words of the caption under the table, in the same grey
as the prose either side of it, distinguished only by a dotted underline.
It rendered and it worked; nobody would ever see it, which is the same as
not shipping it - and I asked for a reading from a control that looked like
a sentence.

It is now an outlined chip in the row beside "Rank four clips", right-
aligned, and it turns accent-coloured while its panel is open. A reader who
has heard nothing is looking for a button, so it looks like one. The panel
moved up with it, directly under the control that opens it rather than
stranded past the caption.

Checked at 1400px and at 390px in WebKit: the chip sits at the end of the
ballot row on desktop and wraps onto its own line on a phone, and the panel
opens with both probes visible in each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
… here

The reader ran the two probes: the test tone was silent, the same clip
through an <audio> element played. That is as clean a result as this could
have given. Web Audio is not recoverable on that browser by any amount of
resume handling - which is what the last three attempts were - so it is no
longer what anyone listens to.

Web Audio was chosen for a real reason: "canplaythrough" is a heuristic
about download rate, so an element could start while the MP3 was still
arriving and swallow the first word, whereas decodeAudioData only resolves
with the whole clip in memory. That guarantee survives without it. Every
clip is fetched to completion and played from a blob URL, so the bytes are
all local before play() is called; the element is never asked to guess.

navigator.audioSession.type = "playback" goes too. It existed to drag Web
Audio out of the "ambient" session that the iOS ring/silent switch mutes -
into the session a media element is in natively. With clips on an element
that is free, and the one Safari-only line in the file, which is the shape
this failure always had, is gone with it.

What follows from element playback rather than a context: one element for
the page, reused by changing src, because iOS grants playback to the
element the reader started rather than to the page. Fetches are shared and
cached per clip. Hovering a row now prefetches it, which is worth more than
the silent buffer it replaces - the click's only remaining wait is the
download. A refused play() is told apart from a clip that will not load, so
"blocked" and "could not be loaded" mean what they say. The context now
exists solely for the tone probe, created only if that probe is run.

Verified in both engines against a production build: the clip plays and its
position advances, stop stops, switching clips leaves exactly one sounding,
and no AudioContext is constructed at all. 12 unit tests rewritten around
the element. Not verified: the row that plays itself after the ranking
dialog closes, which needs the ballot API and so cannot run locally - it
plays through the element the dialog already unlocked, but a reader who
skips the dialog without playing anything may see it refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
They existed to answer one question I could not answer from here, and the
reader answered it in a single round: tone silent, element audible. Keeping
a debugging panel on a published page past that point is clutter on the one
control row a reader actually uses.

Out with it goes the last AudioContext in the file - it was only still there
to power the tone probe - along with the resume-and-wait helper written for
Safari's never-settling resume promise. The player is now an element, a
fetch cache and nothing else: 207 lines against the 300 it peaked at, with
no Web Audio surface left to go wrong.

A failed play still logs with its revision, so the console says what the
panel used to, and the reader still gets the message on the page telling
them whether the browser refused or the clip would not load.

The test that replaces the probe tests asserts the point that now matters:
playing, stopping, switching and prefetching must construct no AudioContext
at all. Its stub is a tripwire, not a fake. Ten tests, and both engines
re-checked against a production build - clip plays, stop stops, zero
contexts, no panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Four findings from a review, all real, though one is milder than billed.

The play() that matters was on the wrong side of the fetch. The comment
claimed playback started inside the click; the code awaited the download
first and only then called play(), which is the thing Safari and iOS refuse.
It survived testing because a hover prefetches and because WebKit forwarded
the activation this time - neither of which holds on a phone, where there is
no hover and the first tap is always uncached. The first play of an uncached
clip now plays a silent inline frame synchronously, claiming the grant while
the activation is live; the element keeps it, so the real clip inherits it
when it lands. Cached clips skip this - there is nothing to wait for.

Rapid switching could play the earlier click. The stale-load guard asked
whether something was already playing, which is false for both clips while
both are still loading, so whichever resolved last took the element. It is
now a generation, bumped on every toggle and every stop, so a load that
lands after the reader has moved on is dropped, and a clip stopped mid-fetch
does not start.

A ballot the server rejected was shown as "Thank you - that is one ballot",
and the parent recorded it as submitted. So a 429, a 409 or a dropped
connection lost the vote, told the reader it had counted, and marked them as
already asked. It now retries once - these failures are mostly transient -
and if that fails too it still reveals the names, which the listening
earned, under an honest heading, and reports back that nothing was counted.

Both ballot routes ran the IP rate limiter a second time, after the global
unauthenticated check had already run, so public readers spent two tokens
per request and authenticated ones paid for a check that does not apply to
them. Removed. The reviewer put this at five requests to the limit; the
configured limit is 200/minute, not the 10 they assumed, so the real effect
was halving a generous budget rather than breaking the third ballot.

Both engines re-checked against a production build: the silent frame is
played before the blob on a first play, three rapid clicks leave exactly one
clip sounding and it is the last one clicked. 38 tests; three of the four
new ones fail against the previous player.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The deferred play was the one path I flagged as unverified when clips moved
to an element, and it is the one that was broken. First click on a row opens
the ranking dialog instead of playing; the row the reader asked for is played
afterwards, from an effect. An effect has no user activation, so the silent
frame that claims Safari's permission could not be spent there - and a reader
who skips the dialog without playing anything in it has nothing else that
could have claimed it. They ask to hear a row, decline a survey, and get
silence.

Closing the dialog is itself a gesture, so that is where the grant is taken.
unlock() is now part of the player's surface rather than a step buried in the
first play, and closeBallot calls it on every close - skip, submit, and
backdrop alike. The effect that honours the original click then plays into an
element that already has permission.

The whole first-time path could not be exercised locally before, because it
needs the ballot API. Serving the ballot from the test makes it runnable, and
it now passes in both engines by both exits: the close writes the silent
frame and then the clip, and the row the reader originally clicked sounds.
Chromium looked like a failure at first and was not - Piper is 2.1s and the
assertion was 2.5s after the click.

Escape is not a close path here; the dialog has no key handler, so every exit
runs through a click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Thirty-four configurations. Supertonic measures 3.61% word error, ahead of
Piper's 3.75% - both print as 4%, so the table shows a tie the underlying
numbers do not - at 0.84s per phrase and 0.22x real time. Nothing else here
is that accurate that cheaply.

The identification took some care, because the export carries only the id
"supertonic". 99M parameters across four ONNX graphs at native 44.1 kHz is
the v3 checkpoint specifically; v1 was 66M and v2 covered five languages.
So it is listed as Supertonic 3, linked to supertone-inc/supertonic.

Its licence proof is the weights' own LICENSE on Hugging Face rather than
the repository's, because the two differ and the table states the weights:
the sample code is MIT, the model is OpenRAIL-M. The repo LICENSE would
have "proved" the wrong one - the same trap F5-TTS and FastPitch set.

Supertone joins the vendor strip, twenty chips now. No existing note is
invalidated: Zonos keeps second place on PESQ, and Chatterbox Turbo's
"joint-best at 4%" survives because at the table's precision it now
describes a three-way tie.

The prose count moves to nineteen of the thirty-four for the medium.en
gaps, recomputed from the export rather than incremented - Supertonic is
one of the configurations it did not finish. The real-time boundary is
untouched: OmniVoice at 1.51x and Chatterbox Q4 at 2.03x still sit either
side of it.

Verified in a build: the row renders and plays, both links resolve 200, the
chart carries 102 markers over 34 rows, and the logo loads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The export dates it 2026-03. The configuration measured is the 99M v3
checkpoint - four ONNX graphs, native 44.1 kHz - and that shipped at the end
of April 2026; March belongs to an earlier one. v1 was 66M and v2, from
January, covered five languages.

An override rather than an edit to the data file, so it survives the next
import like every other correction here, and it carries the sort key as well
as the text - Released sorts on 202604, not on the string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Thirty-six configurations, and a second vendor whose speaker comes from a
written description rather than a reference clip: VoiceDesign is 1.7B at
0.87x real time and 6% word error, effectively its 0.6B sibling's accuracy
for four times the compute per phrase. Orpheus is the largest LM backbone
here alongside TaDA 3B, and the slowest thing in the table at 8.09x.

VoiceDesign points at its own checkpoint rather than the family repository,
since it is a distinct set of weights. Both licence links are model cards
rather than repository LICENSE files: each declares apache-2.0 for the
weights, which is what the column states, and Supertonic is the standing
reminder that a repo can license the code differently.

Canopy Labs joins the vendor strip - twenty-one chips - with their GitHub
avatar, as with every other logo here.

Both prose counts move, recomputed rather than incremented: medium.en now
fails for twenty-one of the thirty-six, and twelve configurations cannot
keep up with their own speech. The real-time boundary is unmoved; Orpheus
is far above it and VoiceDesign comfortably below.

Verified in a build: both rows render, link and play, all three new links
resolve 200, the chart carries 108 markers, and no asset 404s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
The column has been empty since it shipped: at a minimum of eight, with
thirty ballots spread over the field, the best-covered configuration had
seven. So the threshold was not protecting readers from a noisy number, it
was withholding every number.

Simulating the estimator settles what the cutoff is worth. Same MM fit,
ballots drawn from the Plackett-Luce model Bradley-Terry assumes: at thirty
ballots a shown rating carries about twenty points either way whether the
cutoff is three or five, because precision comes from the total number of
ballots and not from the cutoff. Moving 5 -> 3 costs about three points of
error and two points of pairwise accuracy, and triples the rows shown. What
three still earns is excluding a configuration seen once or twice, where the
rating is barely distinguishable from noise; correlation with the truth is
about 0.70 there today, and reaches 0.89 by a hundred and twenty ballots.

Two presentation changes follow from that, because the honest problem with
the column was never the cutoff. It rendered 72.4 - a tenth of a point on a
measurement worth twenty either way - so it now rounds to whole points, with
sorting still on the full value. And the caption says what the numbers are:
early, worth about twenty points either way, a rough ordering rather than a
measurement.

Checked against a payload of the shape the control plane will now return:
ratings render as 72, 64, 50, 28, a configuration below the threshold still
shows a dash, and the caption reads the threshold from the API rather than
restating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
Twenty-one of them wrapped to three, with a single chip stranded on the
last line. The column they sit in is a fixed 1104px whatever the viewport,
so the row count is set purely by chip width - which is why this is the
second trim, and will be needed again around the twenty-third vendor.

Horizontal padding 11px to 9px, the gap between chips 6px to 5px, the gap
between logo and name 8px to 7px, and the label from 0.80rem to 0.78rem.
Height stays at 32px and the logos stay at 16px: both are visible if
shrunk, where two pixels of side padding are not. That leaves 11 + 10 with
about 30px to spare.

Measured rather than guessed - candidate sizings were applied in the page
and the resulting row counts read back, then confirmed against a build at
1280, 1440 and 1920, where it is two rows, and at 1024, 768 and 390, where
it wraps further as it should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WebP1vdtbLxbLjfWY9YGj
@robdmac
robdmac merged commit 7d62a34 into main Aug 21, 2026
1 check passed
@robdmac
robdmac deleted the feat/tts-benchmark branch August 21, 2026 06:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant