"""TTS Arena: blind side-by-side battles between small text-to-speech models.
Two randomly chosen models read the same text, you vote, and their ratings move. Names stay
hidden until the vote. Every text and vote is written to the arena's Hugging Face bucket.
"""
import random
import uuid
import gradio as gr
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import elo
import engines
import registry
import storage
try: # ZeroGPU is only present on the Space
import spaces
except ImportError: # pragma: no cover - local dev
class _Spaces:
@staticmethod
def GPU(*args, **kwargs):
def wrap(fn):
return fn
return wrap if not (args and callable(args[0])) else args[0]
spaces = _Spaces()
ARENA = "tts"
GPU_DURATION = 30
MAX_CHARS = 300
SENTENCES = [
"The museum opens at nine, but the best exhibits are upstairs, past the old clock.",
"If the train is late again, we should probably walk the rest of the way.",
"She folded the letter twice, slipped it into her coat, and never mentioned it again.",
"Fresh bread, a little butter, and a quiet morning: that is all I really need.",
"Please remember to water the plants on the balcony while I am away.",
"The storm knocked out the power, so we told stories by candlelight until midnight.",
"Honestly, I did not expect the sequel to be better than the original.",
"Turn left at the bakery, then keep going until you see the blue door.",
"Our team spent three weeks on the design, and it still needs another review.",
"Why would anyone leave a perfectly good umbrella on the bus?",
"The river was calm that evening, and the whole town came out to watch the lanterns.",
"Could you read that number back to me, slowly this time?",
]
def bootstrap():
"""Rebuild ratings from the vote log (the authoritative history); never overwrite history after a failed read."""
store = elo.EloStore()
try:
applied = store.replay(storage.load_vote_log())
return store, True, f"Ratings rebuilt from {applied} logged votes."
except Exception as exc:
print(f"[arena] vote-log replay failed: {type(exc).__name__}: {exc}")
try:
cached = storage.load_state()
if cached is not None:
return elo.EloStore(cached), True, "Ratings loaded from state/elo.json."
return store, True, "No history yet."
except Exception as exc:
return store, False, (f"Could not read history ({type(exc).__name__}). Ratings shown are for this session only "
"and are not saved. Votes are still logged.")
STORE, STATE_WRITABLE, LOAD_STATUS = bootstrap()
print(f"[arena] {LOAD_STATUS}")
engines.warm([s.repo_id for s in registry.MODELS])
import logging
for _n in ("httpx", "httpcore", "urllib3", "matplotlib", "numba", "phonemizer"):
logging.getLogger(_n).setLevel(logging.WARNING)
VOTE_LABELS = {"a": "A is better", "b": "B is better", "tie": "Tie", "both_bad": "Both bad"}
# --------------------------------------------------------------- generation
@spaces.GPU(duration=GPU_DURATION)
def _run_gpu(repo_ids, text, seed):
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
return [engines.synthesize(r, text, seed, device) for r in repo_ids]
def _run_battle(repo_a, repo_b, text, seed):
"""Torch models share one GPU grant; ONNX / Phonon run on the CPU without waiting for a GPU."""
gpu = [r for r in (repo_a, repo_b) if engines.needs_gpu(r)]
out = dict(zip(gpu, _run_gpu(gpu, text, seed))) if gpu else {}
for r in (repo_a, repo_b):
if r not in out:
out[r] = engines.synthesize(r, text, seed, "cpu")
return out[repo_a], out[repo_b]
def battle(text, profile: gr.OAuthProfile | None = None):
if profile is None:
raise gr.Error("Sign in with Hugging Face to run battles.")
text = " ".join((text or "").split())
if not text:
raise gr.Error("Type something for the models to read first.")
if len(text) > MAX_CHARS:
raise gr.Error(f"Keep it under {MAX_CHARS} characters.")
spec_a, spec_b = random.sample(registry.arena_models(ARENA), 2)
seed = random.randint(0, 2**31 - 1)
battle_id = uuid.uuid4().hex
audio_a, audio_b = _run_battle(spec_a.repo_id, spec_b.repo_id, text, seed)
storage.log_generation({
"battle_id": battle_id, "arena": ARENA, "created_at": storage.utc_now(), "hf_user": profile.username, "prompt": text,
"settings": {"seed": seed},
"model_a": {"repo_id": spec_a.repo_id, "params": spec_a.params, "seconds": len(audio_a[1]) / audio_a[0]},
"model_b": {"repo_id": spec_b.repo_id, "params": spec_b.params, "seconds": len(audio_b[1]) / audio_b[0]},
})
state = {"battle_id": battle_id, "prompt": text, "repo_a": spec_a.repo_id, "repo_b": spec_b.repo_id,
"settings": {"seed": seed}, "voted": False, "hf_user": profile.username}
reveal = '
Listen to both, then vote. Names are hidden until you do.
'
return [audio_a, audio_b, state, reveal, *[gr.update(interactive=True) for _ in VOTE_LABELS]]
# --------------------------------------------------------------- voting
def _name_html(s):
return f'{s.name} ↗' if s.url else f"{s.name}"
def _reveal_html(state, deltas):
rows = []
for side, repo_id in (("A", state["repo_a"]), ("B", state["repo_b"])):
s, d = registry.spec(repo_id), deltas[repo_id]
diff = d["after"] - d["before"]
sign = "up" if diff > 0 else ("down" if diff < 0 else "flat")
rows.append(f'{side}{_name_html(s)}'
f'{s.params_m:.1f}M'
f'{d["before"]:.1f} → {d["after"]:.1f}
')
return 'REVEALED
' + "".join(rows) + \
'
Vote recorded. Type new text or draw a sentence for the next battle.
'
def vote(state, choice, profile: gr.OAuthProfile | None = None):
if profile is None:
raise gr.Error("Sign in with Hugging Face to vote.")
if not state or not state.get("battle_id"):
raise gr.Error("Run a battle first.")
if state.get("voted"):
raise gr.Error("Already voted on this one. Start a new battle.")
if state.get("hf_user") != profile.username:
raise gr.Error("This battle was started by a different account. Start a new battle.")
deltas = STORE.record_vote(ARENA, state["repo_a"], state["repo_b"], choice)
if STATE_WRITABLE:
storage.save_state(STORE.to_dict())
storage.log_vote({
"battle_id": state["battle_id"], "arena": ARENA, "created_at": storage.utc_now(), "hf_user": profile.username, "vote": choice,
"prompt": state["prompt"], "settings": state["settings"],
"model_a": {"repo_id": state["repo_a"], "elo": deltas[state["repo_a"]]},
"model_b": {"repo_id": state["repo_b"], "elo": deltas[state["repo_b"]]},
})
state = dict(state, voted=True)
return [state, _reveal_html(state, deltas), *[gr.update(interactive=False) for _ in VOTE_LABELS],
leaderboard_html(), leaderboard_plot()]
# --------------------------------------------------------------- leaderboard
def leaderboard_html():
rows = STORE.rows(ARENA)
total = STORE.total_battles(ARENA)
body = []
for i, r in enumerate(rows, start=1):
s = registry.spec(r["repo_id"])
wr = f"{r['win_rate'] * 100:.0f}%" if r["battles"] else "—"
body.append(f"| {i:02d} | {_name_html(s)} {s.org} | "
f"{r['elo']:.1f} | {r['params_m']:.1f} | "
f"{r['elo_per_m']:.0f} | {r['battles']} | "
f"{r['wins']} | {r['losses']} | {r['ties']} | "
f"{r['both_bad']} | {wr} |
")
status = "" if STATE_WRITABLE else f"{LOAD_STATUS}
"
return (f"{len(rows)} models · {total} rated battles
{status}"
"| # | Model | Elo | Params (M) | "
"Elo / M | Battles | W | L | T | Both bad | Win rate |
"
f"{''.join(body)}
")
def leaderboard_plot():
rows = STORE.rows(ARENA)
plt.rcParams["font.family"] = "DejaVu Sans Mono"
fig, ax = plt.subplots(figsize=(9, 4.6), dpi=120)
fig.patch.set_facecolor("#0a0a0a")
ax.set_facecolor("#0a0a0a")
for r in rows:
ax.scatter(r["params_m"], r["elo"], s=40 + 5 * min(r["battles"], 40), c="#eeeeee", edgecolors="#0a0a0a", zorder=3)
ax.annotate(r["model"], (r["params_m"], r["elo"]), textcoords="offset points", xytext=(7, 5), fontsize=7.5, color="#9a9a9a")
ax.axhline(elo.ANCHOR, color="#333333", linestyle="--", linewidth=1, zorder=1)
ax.set_xscale("log")
ax.set_xlabel("Parameters (millions, log scale)", color="#9a9a9a")
ax.set_ylabel("Elo", color="#9a9a9a")
ax.tick_params(colors="#9a9a9a")
for sp in ax.spines.values():
sp.set_color("#2a2a2a")
ax.grid(True, which="both", color="#1c1c1c", zorder=0)
fig.tight_layout()
return fig
def load_page():
"""Every page load replays the vote log so the leaderboard is current."""
global STATE_WRITABLE, LOAD_STATUS
try:
applied = STORE.replay(storage.load_vote_log())
STATE_WRITABLE, LOAD_STATUS = True, f"Ratings rebuilt from {applied} logged votes."
except Exception as exc:
print(f"[arena] replay on load failed: {type(exc).__name__}: {exc}")
return leaderboard_html(), leaderboard_plot()
# --------------------------------------------------------------- UI
CSS = """
:root, .dark { --bg:#0a0a0a; --fg:#eeeeee; --muted:#9a9a9a; --line:#2a2a2a; --field:#141414; }
body, gradio-app, .gradio-container, .main, .wrap, .contain { background: var(--bg) !important; color: var(--fg) !important; }
.gradio-container { max-width: 980px !important; margin: 0 auto !important; padding: 0 16px !important;
font-family: "Courier New", ui-monospace, "DejaVu Sans Mono", monospace !important; }
.gradio-container * { font-family: inherit; }
footer { display: none !important; }
.block, .form, .panel, .gr-group, .gr-box { background: transparent !important; border: none !important; box-shadow: none !important; }
#topbar { display:flex; justify-content:space-between; align-items:center; padding:22px 0 22px; border-bottom:1px solid var(--line); }
#topbar .brand { white-space:nowrap; font-family: Helvetica, Arial, sans-serif; font-weight:700; font-size:22px; letter-spacing:-0.04em; color:var(--fg); }
#topbar .mark { display:inline-flex; width:34px; height:34px; background:#000; border:1px solid var(--line); margin-right:12px;
align-items:center; justify-content:center; vertical-align:middle; font-size:15px; }
#topbar a { color: var(--fg); text-decoration:none; font-size:13px; }
.crumb { font-size:11px; letter-spacing:0.25em; color:var(--muted); text-transform:uppercase; margin: 6px 0 14px; }
#hero { padding: 64px 0 10px; }
#hero h1 { font-size: 46px; font-weight: 800; letter-spacing:-0.03em; margin: 0 0 16px; line-height:1.05; color:var(--fg); }
#hero p { color: var(--muted); font-size: 14px; margin:0; max-width: 640px; line-height:1.6; }
.tabs > .tab-nav, div[role="tablist"] { border-bottom: 1px solid var(--line) !important; gap:0 !important; }
button[role="tab"] { background:transparent !important; color:var(--muted) !important; border:none !important; border-bottom:2px solid transparent !important;
border-radius:0 !important; font-size:15px !important; padding:12px 26px !important; flex:1; }
button[role="tab"][aria-selected="true"], button[role="tab"].selected { color:var(--fg) !important; border-bottom:2px solid var(--fg) !important; }
label span, .label-wrap span, span[data-testid="block-info"] { color: var(--fg) !important; font-size:13px !important; background:transparent !important; }
textarea, input[type=text] { background: var(--field) !important; color: var(--fg) !important; border:1px solid var(--line) !important;
border-radius:0 !important; font-size:15px !important; padding:14px 16px !important; }
textarea:focus { border-color:#6b6b6b !important; outline:none !important; box-shadow:none !important; }
button.primary, #go { position:relative; background:#eeeeee !important; color:#0a0a0a !important; border:none !important; border-radius:0 !important;
font-weight:700 !important; font-size:15px !important; padding:16px !important;
clip-path: polygon(8px 0, calc(100% - 8px) 0, calc(100% - 8px) 8px, 100% 8px, 100% calc(100% - 8px), calc(100% - 8px) calc(100% - 8px),
calc(100% - 8px) 100%, 8px 100%, 8px calc(100% - 8px), 0 calc(100% - 8px), 0 8px, 8px 8px); }
button.primary:hover { background:#ffffff !important; }
button.secondary { background:transparent !important; color:var(--fg) !important; border:1px solid var(--line) !important; border-radius:0 !important;
font-size:14px !important; padding:14px !important; }
button.secondary:hover:not(:disabled) { border-color:var(--fg) !important; }
button:disabled { opacity:0.35 !important; cursor:not-allowed; }
.linkbtn, .linkbtn button { white-space:nowrap !important; width:auto !important; flex:none !important; }
.linkbtn { background:transparent !important; border:none !important; color:var(--muted) !important; text-decoration:underline !important;
text-underline-offset:4px; font-size:13px !important; padding:4px 0 !important; min-width:0 !important; box-shadow:none !important; }
.linkbtn:hover { color:var(--fg) !important; }
.side-label { font-size:11px; letter-spacing:0.25em; color:var(--muted); margin:18px 0 6px; }
.audio-box, .audio-box * { background: var(--field) !important; border-color: var(--line) !important; border-radius:0 !important; color:var(--fg) !important; }
.note { color: var(--muted); font-size: 13px; margin: 14px 0; }
.reveal { border-top:1px solid var(--line); margin-top:18px; padding-top:14px; }
.rev-row { display:flex; gap:18px; align-items:baseline; padding:8px 0; border-bottom:1px solid #161616; font-size:15px; flex-wrap:wrap; }
.rev-row .side { color:var(--muted); width:16px; }
.rev-row a, .name a { color:var(--fg); text-decoration:underline; text-underline-offset:4px; text-decoration-color:#555; }
.muted { color: var(--muted); } .small { font-size: 12px; margin-top:12px; }
.elo.up { color:#d8d8d8; } .elo.down { color:#7a7a7a; } .elo { margin-left:auto; }
.lb-meta { color:var(--muted); font-size:13px; margin:22px 0 12px; }
.table-wrap { overflow-x:auto; }
table.lb { width:100%; border-collapse:collapse; font-size:14px; }
table.lb th { text-align:left; font-weight:400; color:var(--muted); font-size:11px; letter-spacing:0.12em; text-transform:uppercase;
padding:10px 8px; border-bottom:1px solid var(--line); white-space:nowrap; }
table.lb td { padding:12px 8px; border-bottom:1px solid #161616; color:var(--fg); }
table.lb td.num, table.lb th:nth-child(n+3) { text-align:right; font-variant-numeric: tabular-nums; }
table.lb td.rank { color:var(--muted); } table.lb td.strong { font-weight:700; }
table.lb .org { color:var(--muted); font-size:11px; margin-top:3px; }
.prose { color: var(--muted); font-size:13.5px; line-height:1.7; max-width: 720px; }
.prose h3 { color: var(--fg); font-size:15px; margin: 26px 0 8px; }
.prose a { color: var(--fg); }
#foot { border-top:1px solid var(--line); margin-top:56px; padding:22px 0 40px; color:var(--muted); font-size:12px; line-height:1.6; }
#votes > * { min-width: 0 !important; }
@media (max-width: 640px) { #votes { display:grid !important; grid-template-columns:1fr 1fr; gap:8px; }
#topbar .muted, .charnote { display:none !important; } #hero { padding-top:36px; } #hero h1 { font-size:34px; } button[role="tab"] { padding:12px 10px !important; } }
"""
FORCE_DARK = """
() => { document.documentElement.classList.add('dark'); document.body.classList.add('dark'); }
"""
THEME = gr.themes.Base(primary_hue="neutral", neutral_hue="neutral", radius_size="none",
font=["Courier New", "ui-monospace", "monospace"],
font_mono=["Courier New", "ui-monospace", "monospace"]).set(
body_background_fill="#0a0a0a", body_background_fill_dark="#0a0a0a", body_text_color="#eeeeee",
body_text_color_dark="#eeeeee", block_background_fill="transparent", block_background_fill_dark="transparent",
block_border_width="0px", input_background_fill_dark="#141414", input_background_fill="#141414",
border_color_primary="#2a2a2a", border_color_primary_dark="#2a2a2a")
ABOUT = f"""
How it works
Type a sentence (or draw one), and two of the {len(registry.MODELS)} models below are picked at random to read it.
Every clip is loudness-matched before playback, so nobody wins by being louder. Each model reads with one fixed
voice. You vote, then the names are revealed.
Rating
Ratings are Elo (400 points per factor of 10 in odds), replayed vote by vote. Each model starts from a rating set by
its automatic benchmark score (TTS Index: WER, UTMOS, SCOREQ, DNSMOS). How much a vote moves a rating depends on the gap:
beating a stronger model gains more than beating a weaker one. Each model also has its own step size, large for its
first battles and shrinking as it plays more, so two models rarely move by the same amount.
Both bad counts as a draw but is tallied separately.
Contenders
{"
".join(f"{_name_html(s)} {s.params_m:.2f}M · {s.org}" for s in registry.MODELS)}
Codename models are unreleased checkpoints, rated blind like everyone else.
Logging
Battles and votes need a Hugging Face sign-in; the leaderboard is open to everyone. Every text you submit and every vote is written to the arena's Hugging Face bucket, together with your Hugging Face username. Don't type personal or sensitive information.
"""
with gr.Blocks(title="TTS Arena", theme=THEME, css=CSS, js=FORCE_DARK) as demo:
gr.HTML('▶TTS Arena
'
'
small models · blind votes '
'ARENA / TEXT-TO-SPEECH
Which voice
sounds better?
'
'
Blind battles between small text-to-speech models. Two read the same text, you pick the better one.
')
with gr.Tabs():
with gr.Tab("Arena"):
gr.LoginButton(value="Sign in with Hugging Face to battle and vote")
text = gr.Textbox(label="Text", placeholder="Type a sentence for both models to read…", lines=3, max_lines=6,
value=random.choice(SENTENCES))
with gr.Row():
draw = gr.Button("Draw a random sentence", elem_classes="linkbtn", scale=0)
gr.HTML(f'max {MAX_CHARS} characters
', elem_classes="charnote")
go = gr.Button("Battle ↗", variant="primary", elem_id="go")
with gr.Row(equal_height=True):
with gr.Column():
gr.HTML('MODEL A
')
out_a = gr.Audio(show_label=False, interactive=False, elem_classes="audio-box", show_download_button=False)
with gr.Column():
gr.HTML('MODEL B
')
out_b = gr.Audio(show_label=False, interactive=False, elem_classes="audio-box", show_download_button=False)
with gr.Row(elem_id="votes"):
btn_a = gr.Button("← " + VOTE_LABELS["a"], interactive=False, variant="secondary")
btn_tie = gr.Button(VOTE_LABELS["tie"], interactive=False, variant="secondary")
btn_bad = gr.Button(VOTE_LABELS["both_bad"], interactive=False, variant="secondary")
btn_b = gr.Button(VOTE_LABELS["b"] + " →", interactive=False, variant="secondary")
reveal = gr.HTML('Press Battle to hear two random models.
')
with gr.Tab("Leaderboard"):
board = gr.HTML(leaderboard_html())
plot = gr.Plot(value=leaderboard_plot(), show_label=False)
refresh = gr.Button("Refresh", elem_classes="linkbtn")
with gr.Tab("About"):
gr.HTML(ABOUT)
gr.HTML('')
state = gr.State({})
votes = [btn_a, btn_b, btn_tie, btn_bad]
draw.click(lambda: random.choice(SENTENCES), outputs=text)
go.click(battle, inputs=text, outputs=[out_a, out_b, state, reveal, *votes], api_name="battle")
def _voter(c):
def fn(s, profile: gr.OAuthProfile | None = None):
return vote(s, c, profile)
return fn
for choice, b in (("a", btn_a), ("b", btn_b), ("tie", btn_tie), ("both_bad", btn_bad)):
b.click(_voter(choice), inputs=state, outputs=[state, reveal, *votes, board, plot])
refresh.click(load_page, outputs=[board, plot])
demo.load(load_page, outputs=[board, plot])
if __name__ == "__main__":
demo.queue(max_size=32).launch()