Zachary Greathouse commited on
Commit
de305ed
·
unverified ·
1 Parent(s): dc5aac3

Zg/add leaderboard (#11)

Browse files

Add huggingface configuration and leaderboard UI

.github/workflows/sync-to-huggingface.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Space
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ sync-to-hub:
10
+ runs-on: ubuntu-latest
11
+ environment: huggingface-space
12
+ steps:
13
+ - uses: actions/checkout@v3
14
+ with:
15
+ fetch-depth: 0
16
+
17
+ - name: Push to Hugging Face
18
+ env:
19
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
20
+ run: |
21
+ git remote add space https://huggingface.co/spaces/HumeAI/expressive-tts-arena
22
+ git config user.name "github-actions[bot]"
23
+ git config user.email "github-actions[bot]@users.noreply.github.com"
24
+ git push --force https://username:${{ secrets.HF_TOKEN }}@huggingface.co/spaces/HumeAI/expressive-tts-arena main
.huggingface-space ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ title: Expressive TTS Arena
2
+ emoji: 🎤
3
+ colorFrom: indigo
4
+ colorTo: orange
5
+ sdk: docker
6
+ app_file: src/main.py
7
+ python_version: "3.11"
8
+ pinned: true
9
+ license: mit
README.md CHANGED
@@ -24,9 +24,8 @@ For support or to join the conversation, visit our [Discord](https://discord.com
24
 
25
  ```
26
  Expressive TTS Arena/
27
- ├── src/
28
- ├── assets/
29
- │ │ ├── styles.css # Defines custom css
30
  │ ├── database/
31
  │ │ ├── __init__.py # Makes database a package; expose ORM methods
32
  │ │ ├── crud.py # Defines operations for interacting with database
@@ -50,9 +49,12 @@ Expressive TTS Arena/
50
  │ └── utils.py # Utility functions
51
  │── static/
52
  │ ├── audio/ # Directory for storing generated audio files
 
 
53
  ├── .dockerignore
54
  ├── .env.example
55
  ├── .gitignore
 
56
  ├── .pre-commit-config.yaml
57
  ├── Dockerfile
58
  ├── LICENSE.txt
 
24
 
25
  ```
26
  Expressive TTS Arena/
27
+ ├── public/ # Directory for public assets
28
+ ├── src/
 
29
  │ ├── database/
30
  │ │ ├── __init__.py # Makes database a package; expose ORM methods
31
  │ │ ├── crud.py # Defines operations for interacting with database
 
49
  │ └── utils.py # Utility functions
50
  │── static/
51
  │ ├── audio/ # Directory for storing generated audio files
52
+ │ ├── css/
53
+ │ │ ├── styles.css # Defines custom css
54
  ├── .dockerignore
55
  ├── .env.example
56
  ├── .gitignore
57
+ ├── .huggingface-space
58
  ├── .pre-commit-config.yaml
59
  ├── Dockerfile
60
  ├── LICENSE.txt
{src/assets → public}/arena-opengraph-logo.png RENAMED
File without changes
pyproject.toml CHANGED
@@ -47,6 +47,7 @@ ignore = [
47
  "PLR0913",
48
  "PLR0915",
49
  "PLR2004",
 
50
  "RUF006",
51
  "SIM117",
52
  "TD002",
 
47
  "PLR0913",
48
  "PLR0915",
49
  "PLR2004",
50
+ "RET504",
51
  "RUF006",
52
  "SIM117",
53
  "TD002",
src/constants.py CHANGED
@@ -8,7 +8,12 @@ This module defines global constants used throughout the project.
8
  from typing import Dict, List
9
 
10
  # Third-Party Library Imports
11
- from src.custom_types import ComparisonType, OptionKey, OptionLabel, TTSProviderName
 
 
 
 
 
12
 
13
  CLIENT_ERROR_CODE = 400
14
  SERVER_ERROR_CODE = 500
@@ -18,7 +23,18 @@ RATE_LIMIT_ERROR_CODE = 429
18
  # UI constants
19
  HUME_AI: TTSProviderName = "Hume AI"
20
  ELEVENLABS: TTSProviderName = "ElevenLabs"
 
21
  TTS_PROVIDERS: List[TTSProviderName] = ["Hume AI", "ElevenLabs"]
 
 
 
 
 
 
 
 
 
 
22
 
23
  HUME_TO_HUME: ComparisonType = "Hume AI - Hume AI"
24
  HUME_TO_ELEVENLABS: ComparisonType = "Hume AI - ElevenLabs"
@@ -34,8 +50,6 @@ OPTION_B_KEY: OptionKey = "option_b"
34
  OPTION_A_LABEL: OptionLabel = "Option A"
35
  OPTION_B_LABEL: OptionLabel = "Option B"
36
 
37
- TROPHY_EMOJI: str = "🏆"
38
-
39
  SELECT_OPTION_A: str = "Select Option A"
40
  SELECT_OPTION_B: str = "Select Option B"
41
 
@@ -143,3 +157,4 @@ META_TAGS: List[Dict[str, str]] = [
143
  'content': '/static/arena-opengraph-logo.png'
144
  }
145
  ]
 
 
8
  from typing import Dict, List
9
 
10
  # Third-Party Library Imports
11
+ from src.custom_types import (
12
+ ComparisonType,
13
+ OptionKey,
14
+ OptionLabel,
15
+ TTSProviderName,
16
+ )
17
 
18
  CLIENT_ERROR_CODE = 400
19
  SERVER_ERROR_CODE = 500
 
23
  # UI constants
24
  HUME_AI: TTSProviderName = "Hume AI"
25
  ELEVENLABS: TTSProviderName = "ElevenLabs"
26
+
27
  TTS_PROVIDERS: List[TTSProviderName] = ["Hume AI", "ElevenLabs"]
28
+ TTS_PROVIDER_LINKS = {
29
+ "Hume AI": {
30
+ "provider_link": "https://hume.ai/",
31
+ "model_link": "https://www.hume.ai/blog/octave-the-first-text-to-speech-model-that-understands-what-its-saying"
32
+ },
33
+ "ElevenLabs": {
34
+ "provider_link": "https://elevenlabs.io/",
35
+ "model_link": "https://elevenlabs.io/blog/rvg",
36
+ }
37
+ }
38
 
39
  HUME_TO_HUME: ComparisonType = "Hume AI - Hume AI"
40
  HUME_TO_ELEVENLABS: ComparisonType = "Hume AI - ElevenLabs"
 
50
  OPTION_A_LABEL: OptionLabel = "Option A"
51
  OPTION_B_LABEL: OptionLabel = "Option B"
52
 
 
 
53
  SELECT_OPTION_A: str = "Select Option A"
54
  SELECT_OPTION_B: str = "Select Option B"
55
 
 
157
  'content': '/static/arena-opengraph-logo.png'
158
  }
159
  ]
160
+
src/custom_types.py CHANGED
@@ -5,7 +5,7 @@ This module defines custom types for the application.
5
  """
6
 
7
  # Standard Library Imports
8
- from typing import Literal, NamedTuple, Optional, TypedDict
9
 
10
  TTSProviderName = Literal["Hume AI", "ElevenLabs"]
11
  """TTSProviderName represents the allowed provider names for TTS services."""
@@ -83,3 +83,24 @@ class OptionMap(TypedDict):
83
 
84
  option_a: OptionDetail
85
  option_b: OptionDetail
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  # Standard Library Imports
8
+ from typing import List, Literal, NamedTuple, Optional, TypedDict
9
 
10
  TTSProviderName = Literal["Hume AI", "ElevenLabs"]
11
  """TTSProviderName represents the allowed provider names for TTS services."""
 
83
 
84
  option_a: OptionDetail
85
  option_b: OptionDetail
86
+
87
+
88
+ class LeaderboardEntry(NamedTuple):
89
+ """A leaderboard entry representing rank, TTS provider, and scores.
90
+
91
+ Attributes:
92
+ rank (int): Rank position on the leaderboard.
93
+ provider (str): TTS provider's name.
94
+ model (str): The provider's TTS model.
95
+ win_rate (str): The provider's win rate as a percentage (e.g., "90%").
96
+ votes (int): The total number of votes cast for the provider (e.g., 90).
97
+ """
98
+ rank: str
99
+ provider: str
100
+ model: str
101
+ win_rate: str
102
+ votes: str
103
+
104
+
105
+ LeaderboardTableEntries = List[LeaderboardEntry]
106
+ """List of multiple leaderboard entries."""
src/database/crud.py CHANGED
@@ -6,10 +6,13 @@ Since vote records are never updated or deleted, only functions to create and re
6
  """
7
 
8
  # Third-Party Library Imports
 
 
9
  from sqlalchemy.ext.asyncio import AsyncSession
10
 
11
  # Local Application Imports
12
- from src.custom_types import VotingResults
 
13
  from src.database.models import VoteResult
14
 
15
 
@@ -24,23 +27,129 @@ async def create_vote(db: AsyncSession, vote_data: VotingResults) -> VoteResult:
24
  Returns:
25
  VoteResult: The newly created vote record.
26
  """
27
- vote = VoteResult(
28
- comparison_type=vote_data["comparison_type"],
29
- winning_provider=vote_data["winning_provider"],
30
- winning_option=vote_data["winning_option"],
31
- option_a_provider=vote_data["option_a_provider"],
32
- option_b_provider=vote_data["option_b_provider"],
33
- option_a_generation_id=vote_data["option_a_generation_id"],
34
- option_b_generation_id=vote_data["option_b_generation_id"],
35
- voice_description=vote_data["character_description"],
36
- text=vote_data["text"],
37
- is_custom_text=vote_data["is_custom_text"],
38
- )
39
- db.add(vote)
40
  try:
41
- await db.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  except Exception as e:
43
- await db.rollback()
44
- raise e
45
- await db.refresh(vote)
46
- return vote
 
6
  """
7
 
8
  # Third-Party Library Imports
9
+ from sqlalchemy import text
10
+ from sqlalchemy.exc import SQLAlchemyError
11
  from sqlalchemy.ext.asyncio import AsyncSession
12
 
13
  # Local Application Imports
14
+ from src.config import logger
15
+ from src.custom_types import LeaderboardEntry, LeaderboardTableEntries, VotingResults
16
  from src.database.models import VoteResult
17
 
18
 
 
27
  Returns:
28
  VoteResult: The newly created vote record.
29
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  try:
31
+
32
+ # Create vote record
33
+ vote = VoteResult(
34
+ comparison_type=vote_data["comparison_type"],
35
+ winning_provider=vote_data["winning_provider"],
36
+ winning_option=vote_data["winning_option"],
37
+ option_a_provider=vote_data["option_a_provider"],
38
+ option_b_provider=vote_data["option_b_provider"],
39
+ option_a_generation_id=vote_data["option_a_generation_id"],
40
+ option_b_generation_id=vote_data["option_b_generation_id"],
41
+ voice_description=vote_data["character_description"],
42
+ text=vote_data["text"],
43
+ is_custom_text=vote_data["is_custom_text"],
44
+ )
45
+
46
+ db.add(vote)
47
+
48
+ try:
49
+ await db.commit()
50
+ await db.refresh(vote)
51
+ logger.info(f"Vote record created successfully: ID={vote.id}")
52
+ return vote
53
+ except SQLAlchemyError as db_error:
54
+ await db.rollback()
55
+ logger.error(f"Database error while creating vote: {db_error}")
56
+ raise
57
+ except ValueError as val_error:
58
+ logger.error(f"Invalid vote data: {val_error}")
59
+ raise
60
+ except Exception as e:
61
+ if db:
62
+ try:
63
+ await db.rollback()
64
+ except Exception as rollback_error:
65
+ logger.error(f"Error during rollback operation: {rollback_error}")
66
+
67
+ logger.error(f"Unexpected error creating vote record: {e}")
68
+ raise
69
+
70
+
71
+ async def get_leaderboard_stats(db: AsyncSession) -> LeaderboardTableEntries:
72
+ """
73
+ Fetches voting statistics from the database to populate a leaderboard.
74
+
75
+ This function calculates voting statistics for TTS providers, excluding Hume-to-Hume
76
+ comparisons, and returns data structured for a leaderboard display.
77
+
78
+ Args:
79
+ db (AsyncSession): The SQLAlchemy async database session.
80
+
81
+ Returns:
82
+ LeaderboardTableEntries: A list of LeaderboardEntry objects containing rank,
83
+ provider name, model name, win rate, and total votes.
84
+ """
85
+ default_leaderboard = [
86
+ LeaderboardEntry("1", "", "", "0%", "0"),
87
+ LeaderboardEntry("2", "", "", "0%", "0")
88
+ ]
89
+
90
+ try:
91
+ query = text(
92
+ """
93
+ WITH provider_stats AS (
94
+ -- Get wins for Hume AI
95
+ SELECT
96
+ 'Hume AI' as provider,
97
+ COUNT(*) as total_comparisons,
98
+ SUM(CASE WHEN winning_provider = 'Hume AI' THEN 1 ELSE 0 END) as wins
99
+ FROM vote_results
100
+ WHERE comparison_type != 'Hume AI - Hume AI'
101
+
102
+ UNION ALL
103
+
104
+ -- Get wins for ElevenLabs
105
+ SELECT
106
+ 'ElevenLabs' as provider,
107
+ COUNT(*) as total_comparisons,
108
+ SUM(CASE WHEN winning_provider = 'ElevenLabs' THEN 1 ELSE 0 END) as wins
109
+ FROM vote_results
110
+ WHERE comparison_type != 'Hume AI - Hume AI'
111
+ )
112
+ SELECT
113
+ provider,
114
+ CASE
115
+ WHEN provider = 'Hume AI' THEN 'Octave'
116
+ WHEN provider = 'ElevenLabs' THEN 'Voice Design'
117
+ END as model,
118
+ CASE
119
+ WHEN total_comparisons > 0 THEN ROUND((wins * 100.0 / total_comparisons)::numeric, 2)
120
+ ELSE 0
121
+ END as win_rate,
122
+ wins as total_votes
123
+ FROM provider_stats
124
+ ORDER BY win_rate DESC;
125
+ """
126
+ )
127
+
128
+ result = await db.execute(query)
129
+ rows = result.fetchall()
130
+
131
+ # Format the data for the leaderboard
132
+ leaderboard_data = []
133
+ for i, row in enumerate(rows, 1):
134
+ provider, model, win_rate, total_votes = row
135
+ leaderboard_entry = LeaderboardEntry(
136
+ rank=f"{i}",
137
+ provider=provider,
138
+ model=model,
139
+ win_rate=f"{win_rate}%",
140
+ votes=f"{total_votes}"
141
+ )
142
+ leaderboard_data.append(leaderboard_entry)
143
+
144
+ # If no data was found, return default entries
145
+ if not leaderboard_data:
146
+ return default_leaderboard
147
+
148
+ return leaderboard_data
149
+
150
+ except SQLAlchemyError as e:
151
+ logger.error(f"Database error while fetching leaderboard stats: {e}")
152
+ return default_leaderboard
153
  except Exception as e:
154
+ logger.error(f"Unexpected error while fetching leaderboard stats: {e}")
155
+ return default_leaderboard
 
 
src/database/database.py CHANGED
@@ -74,26 +74,19 @@ def init_db(config: Config) -> AsyncDBSessionMaker:
74
  # ruff doesn't like setting global variables, but this is practical here
75
  global engine # noqa
76
 
77
- # Convert standard PostgreSQL URL to async format
78
- def convert_to_async_url(url: str) -> str:
79
- # Convert postgresql:// to postgresql+asyncpg://
80
- if url.startswith('postgresql://'):
81
- return url.replace('postgresql://', 'postgresql+asyncpg://', 1)
82
- return url
83
-
84
  if config.app_env == "prod":
85
  # In production, a valid DATABASE_URL is required.
86
  if not config.database_url:
87
  raise ValueError("DATABASE_URL must be set in production!")
88
 
89
- async_db_url = convert_to_async_url(config.database_url)
90
  engine = create_async_engine(async_db_url)
91
 
92
  return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
93
 
94
  # In development, if a DATABASE_URL is provided, use it.
95
  if config.database_url:
96
- async_db_url = convert_to_async_url(config.database_url)
97
  engine = create_async_engine(async_db_url)
98
 
99
  return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
 
74
  # ruff doesn't like setting global variables, but this is practical here
75
  global engine # noqa
76
 
 
 
 
 
 
 
 
77
  if config.app_env == "prod":
78
  # In production, a valid DATABASE_URL is required.
79
  if not config.database_url:
80
  raise ValueError("DATABASE_URL must be set in production!")
81
 
82
+ async_db_url = config.database_url
83
  engine = create_async_engine(async_db_url)
84
 
85
  return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
86
 
87
  # In development, if a DATABASE_URL is provided, use it.
88
  if config.database_url:
89
+ async_db_url = config.database_url
90
  engine = create_async_engine(async_db_url)
91
 
92
  return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
src/frontend.py CHANGED
@@ -11,7 +11,7 @@ Users can compare the outputs and vote for their favorite in an interactive UI.
11
  # Standard Library Imports
12
  import asyncio
13
  import time
14
- from typing import Tuple
15
 
16
  # Third-Party Library Imports
17
  import gradio as gr
@@ -32,6 +32,7 @@ from src.integrations import (
32
  from src.utils import (
33
  create_shuffled_tts_options,
34
  determine_selected_option,
 
35
  get_random_provider,
36
  submit_voting_results,
37
  validate_character_description_length,
@@ -42,11 +43,19 @@ from src.utils import (
42
  class Frontend:
43
  config: Config
44
  db_session_maker: AsyncDBSessionMaker
 
45
 
46
  def __init__(self, config: Config, db_session_maker: AsyncDBSessionMaker):
47
  self.config = config
48
  self.db_session_maker = db_session_maker
49
 
 
 
 
 
 
 
 
50
  async def _generate_text(self, character_description: str) -> Tuple[gr.Textbox, str]:
51
  """
52
  Validates the character_description and generates text using Anthropic API.
@@ -220,12 +229,11 @@ class Frontend:
220
  character_description,
221
  text,
222
  self.db_session_maker,
223
- self.config,
224
  )
225
  )
226
 
227
  # Build button text to display results
228
- selected_label = f"{selected_provider} {constants.TROPHY_EMOJI}"
229
  other_label = f"{other_provider}"
230
 
231
  return (
@@ -267,6 +275,21 @@ class Frontend:
267
  gr.update(value=character_description), # Update character description
268
  )
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  def _disable_ui(self) -> Tuple[
271
  gr.Button,
272
  gr.Dropdown,
@@ -345,73 +368,84 @@ class Frontend:
345
  False, # Reset should_enable_vote_buttons state
346
  )
347
 
348
- def _build_heading_section(self) -> Tuple[gr.HTML, gr.HTML]:
349
  """
350
- Builds heading section including title, randomize all button, and instructions
351
  """
352
- with gr.Row():
353
- with gr.Column(scale=5):
354
- title_with_social_links = gr.HTML(
355
- """
356
- <div class="title-container">
357
- <h1>Expressive TTS Arena</h1>
358
- <div class="social-links">
359
- <a
360
- href="https://discord.com/invite/humeai"
361
- target="_blank"
362
- id="discord-link"
363
- title="Join our Discord"
364
- aria-label="Join our Discord server"
365
- ></a>
366
- <a
367
- href="https://github.com/HumeAI/expressive-tts-arena"
368
- target="_blank"
369
- id="github-link"
370
- title="View on GitHub"
371
- aria-label="View project on GitHub"
372
- ></a>
373
- </div>
374
- </div>
375
- """
376
- )
377
- instructions = gr.HTML(
378
  """
379
- <h2 style="font-size: 16px;">Instructions</h2>
380
- <ol style="margin-left: 12px;">
381
- <li>
382
- Select a sample character, or input a custom character description and click
383
- <strong>"Generate Text"</strong>, to generate your text input.
384
- </li>
385
- <li>
386
- Click the <strong>"Synthesize Speech"</strong> button to synthesize two TTS outputs based on
387
- your text and character description.
388
- </li>
389
- <li>
390
- Listen to both audio samples to compare their expressiveness.
391
- </li>
392
- <li>
393
- Vote for the most expressive result by clicking either <strong>"Select Option A"</strong> or
394
- <strong>"Select Option B"</strong>.
395
- </li>
396
- </ol>
 
 
 
 
 
 
 
397
  """
398
  )
399
- return (title_with_social_links, instructions)
400
 
401
- def _build_input_section(self) -> Tuple[gr.Button, gr.Dropdown, gr.Textbox, gr.Button, gr.Textbox, gr.Button]:
402
  """
403
- Builds the input section including the sample character description dropdown, character
404
- description input, and generate text button.
405
  """
406
- with gr.Group():
407
- randomize_all_button = gr.Button("🎲 Randomize All", variant="primary")
408
- sample_character_description_dropdown = gr.Dropdown(
409
- choices=list(constants.SAMPLE_CHARACTER_DESCRIPTIONS.keys()),
410
- label="Sample Characters",
411
- info="Generate text with a sample character description.",
412
- value=None,
413
- interactive=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  )
 
 
 
 
 
 
 
 
 
415
  with gr.Group():
416
  character_description_input = gr.Textbox(
417
  label="Character Description",
@@ -422,6 +456,7 @@ class Frontend:
422
  show_copy_button=True,
423
  )
424
  generate_text_button = gr.Button("Generate Text", variant="secondary")
 
425
  with gr.Group():
426
  text_input = gr.Textbox(
427
  label="Input Text",
@@ -433,20 +468,8 @@ class Frontend:
433
  max_length=constants.CHARACTER_DESCRIPTION_MAX_LENGTH,
434
  show_copy_button=True,
435
  )
436
- synthesize_speech_button = gr.Button("Synthesize Speech", variant="primary")
437
- return (
438
- randomize_all_button,
439
- sample_character_description_dropdown,
440
- character_description_input,
441
- generate_text_button,
442
- text_input,
443
- synthesize_speech_button,
444
- )
445
 
446
- def _build_output_section(self) -> Tuple[gr.Audio, gr.Audio, gr.Button, gr.Button, gr.Textbox, gr.Textbox]:
447
- """
448
- Builds the output section including text input, audio players, vote buttons, and vote result displays.
449
- """
450
 
451
  with gr.Row(equal_height=True):
452
  with gr.Column():
@@ -481,333 +504,359 @@ class Frontend:
481
  text_align="center",
482
  container=False,
483
  )
484
- return (
485
- option_a_audio_player,
486
- option_b_audio_player,
487
- vote_button_a,
488
- vote_button_b,
489
- vote_result_a,
490
- vote_result_b,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  )
492
 
493
- def build_gradio_interface(self) -> gr.Blocks:
494
- """
495
- Builds and configures the Gradio user interface.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
 
497
- Returns:
498
- gr.Blocks: The fully constructed Gradio UI layout.
499
- """
500
- with gr.Blocks(
501
- title="Expressive TTS Arena",
502
- css_paths="src/assets/styles.css",
503
- ) as demo:
504
- # --- UI components ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
 
506
- (
507
- title_with_social_links,
508
- instructions,
509
- ) = self._build_heading_section()
510
- (
 
 
 
 
511
  randomize_all_button,
512
  sample_character_description_dropdown,
513
  character_description_input,
514
  generate_text_button,
515
  text_input,
516
  synthesize_speech_button,
517
- ) = self._build_input_section()
518
- (
 
 
 
 
 
519
  option_a_audio_player,
520
  option_b_audio_player,
521
  vote_button_a,
522
  vote_button_b,
523
  vote_result_a,
524
  vote_result_b,
525
- ) = self._build_output_section()
526
-
527
- # --- UI state components ---
528
-
529
- # Track character description used for text and voice generation
530
- character_description_state = gr.State("")
531
- # Track text used for speech synthesis
532
- text_state = gr.State("")
533
- # Track generated text state
534
- generated_text_state = gr.State("")
535
- # Track whether text that was used was generated or modified/custom
536
- text_modified_state = gr.State()
537
- # Track option map (option A and option B are randomized)
538
- option_map_state = gr.State({}) # OptionMap state as a dictionary
539
- # Track whether the user has voted for an option
540
- vote_submitted_state = gr.State(False)
541
- # Track whether the vote buttons should be enabled
542
- should_enable_vote_buttons = gr.State(False)
543
-
544
- # --- Register event handlers ---
545
-
546
- # "Randomize All" button click event handler chain
547
- # 1. Disable interactive UI components
548
- # 2. Reset UI state for audio players and voting results
549
- # 3. Select random sample character description
550
- # 4. Generate text
551
- # 5. Synthesize speech
552
- # 6. Enable interactive UI components
553
- randomize_all_button.click(
554
- fn=self._disable_ui,
555
- inputs=[],
556
- outputs=[
557
- randomize_all_button,
558
- sample_character_description_dropdown,
559
- character_description_input,
560
- generate_text_button,
561
- text_input,
562
- synthesize_speech_button,
563
- vote_button_a,
564
- vote_button_b,
565
- ],
566
- ).then(
567
- fn=self._reset_voting_ui,
568
- inputs=[],
569
- outputs=[
570
- option_a_audio_player,
571
- option_b_audio_player,
572
- vote_button_a,
573
- vote_button_b,
574
- vote_result_a,
575
- vote_result_b,
576
- option_map_state,
577
- vote_submitted_state,
578
- should_enable_vote_buttons,
579
- ],
580
- ).then(
581
- fn=self._randomize_character_description,
582
- inputs=[],
583
- outputs=[sample_character_description_dropdown, character_description_input],
584
- ).then(
585
- fn=self._generate_text,
586
- inputs=[character_description_input],
587
- outputs=[text_input, generated_text_state],
588
- ).then(
589
- fn=self._synthesize_speech,
590
- inputs=[character_description_input, text_input, generated_text_state],
591
- outputs=[
592
- option_a_audio_player,
593
- option_b_audio_player,
594
- option_map_state,
595
- text_modified_state,
596
- text_state,
597
- character_description_state,
598
- should_enable_vote_buttons,
599
- ],
600
- ).then(
601
- fn=self._enable_ui,
602
- inputs=[should_enable_vote_buttons],
603
- outputs=[
604
- randomize_all_button,
605
- sample_character_description_dropdown,
606
- character_description_input,
607
- generate_text_button,
608
- text_input,
609
- synthesize_speech_button,
610
- vote_button_a,
611
- vote_button_b,
612
- ],
613
- )
614
 
615
- # "Sample Characters" dropdown select event handler chain:
616
- # 1. Update Character Description field with sample
617
- # 2. Disable interactive UI components
618
- # 3. Generate text
619
- # 4. Enable interactive UI components
620
- sample_character_description_dropdown.select(
621
- fn=lambda choice: constants.SAMPLE_CHARACTER_DESCRIPTIONS.get(choice, ""),
622
- inputs=[sample_character_description_dropdown],
623
- outputs=[character_description_input],
624
- ).then(
625
- fn=self._disable_ui,
626
- inputs=[],
627
- outputs=[
628
- randomize_all_button,
629
- sample_character_description_dropdown,
630
- character_description_input,
631
- generate_text_button,
632
- text_input,
633
- synthesize_speech_button,
634
- vote_button_a,
635
- vote_button_b,
636
- ],
637
- ).then(
638
- fn=self._generate_text,
639
- inputs=[character_description_input],
640
- outputs=[text_input, generated_text_state],
641
- ).then(
642
- fn=self._enable_ui,
643
- inputs=[should_enable_vote_buttons],
644
- outputs=[
645
- randomize_all_button,
646
- sample_character_description_dropdown,
647
- character_description_input,
648
- generate_text_button,
649
- text_input,
650
- synthesize_speech_button,
651
- vote_button_a,
652
- vote_button_b,
653
- ],
654
- )
655
 
656
- # "Generate Text" button click event handler chain:
657
- # 1. Disable interactive UI components
658
- # 2. Generate text
659
- # 3. Enable interactive UI components
660
- generate_text_button.click(
661
- fn=self._disable_ui,
662
- inputs=[],
663
- outputs=[
664
- randomize_all_button,
665
- sample_character_description_dropdown,
666
- character_description_input,
667
- generate_text_button,
668
- text_input,
669
- synthesize_speech_button,
670
- vote_button_a,
671
- vote_button_b,
672
- ],
673
- ).then(
674
- fn=self._generate_text,
675
- inputs=[character_description_input],
676
- outputs=[text_input, generated_text_state],
677
- ).then(
678
- fn=self._enable_ui,
679
- inputs=[should_enable_vote_buttons],
680
- outputs=[
681
- randomize_all_button,
682
- sample_character_description_dropdown,
683
- character_description_input,
684
- generate_text_button,
685
- text_input,
686
- synthesize_speech_button,
687
- vote_button_a,
688
- vote_button_b,
689
- ],
690
- )
691
 
692
- # "Synthesize Speech" button click event handler chain:
693
- # 1. Disable components in the UI
694
- # 2. Reset UI state for audio players and voting results
695
- # 3. Synthesize speech, load audio players, and display vote button
696
- # 4. Enable interactive components in the UI
697
- synthesize_speech_button.click(
698
- fn=self._disable_ui,
699
- inputs=[],
700
- outputs=[
701
- randomize_all_button,
702
- sample_character_description_dropdown,
703
- character_description_input,
704
- generate_text_button,
705
- text_input,
706
- synthesize_speech_button,
707
- vote_button_a,
708
- vote_button_b,
709
- ],
710
- ).then(
711
- fn=self._reset_voting_ui,
712
- inputs=[],
713
- outputs=[
714
- option_a_audio_player,
715
- option_b_audio_player,
716
- vote_button_a,
717
- vote_button_b,
718
- vote_result_a,
719
- vote_result_b,
720
- option_map_state,
721
- vote_submitted_state,
722
- should_enable_vote_buttons,
723
- ],
724
- ).then(
725
- fn=self._synthesize_speech,
726
- inputs=[character_description_input, text_input, generated_text_state],
727
- outputs=[
728
- option_a_audio_player,
729
- option_b_audio_player,
730
- option_map_state,
731
- text_modified_state,
732
- text_state,
733
- character_description_state,
734
- should_enable_vote_buttons,
735
- ],
736
- ).then(
737
- fn=self._enable_ui,
738
- inputs=[should_enable_vote_buttons],
739
- outputs=[
740
- randomize_all_button,
741
- sample_character_description_dropdown,
742
- character_description_input,
743
- generate_text_button,
744
- text_input,
745
- synthesize_speech_button,
746
- vote_button_a,
747
- vote_button_b,
748
- ],
749
- )
750
 
751
- # "Select Option A" button click event handler chain:
752
- vote_button_a.click(
753
- fn=lambda _=None: (gr.update(interactive=False), gr.update(interactive=False)),
754
- inputs=[],
755
- outputs=[vote_button_a, vote_button_b],
756
- ).then(
757
- fn=self._vote,
758
- inputs=[
759
- vote_submitted_state,
760
- option_map_state,
761
- vote_button_a,
762
- text_modified_state,
763
- character_description_state,
764
- text_state,
765
- ],
766
- outputs=[
767
- vote_submitted_state,
768
- vote_button_a,
769
- vote_button_b,
770
- vote_result_a,
771
- vote_result_b,
772
- synthesize_speech_button,
773
- ],
774
  )
775
 
776
- # "Select Option B" button click event handler chain:
777
- vote_button_b.click(
778
- fn=lambda _=None: (gr.update(interactive=False), gr.update(interactive=False)),
779
- inputs=[],
780
- outputs=[vote_button_a, vote_button_b],
781
- ).then(
782
- fn=self._vote,
783
- inputs=[
784
- vote_submitted_state,
785
- option_map_state,
786
- vote_button_b,
787
- text_modified_state,
788
- character_description_state,
789
- text_state,
790
- ],
791
- outputs=[
792
- vote_submitted_state,
793
- vote_button_a,
794
- vote_button_b,
795
- vote_result_a,
796
- vote_result_b,
797
- synthesize_speech_button,
798
- ],
799
  )
800
 
801
- # Audio Player A stop event handler
802
- option_a_audio_player.stop(
803
- # Workaround to play both audio samples back-to-back
804
- fn=lambda option_map: gr.update(
805
- value=f"{option_map['option_b']['audio_file_path']}?t={int(time.time())}",
806
- autoplay=True,
807
- ),
808
- inputs=[option_map_state],
809
- outputs=[option_b_audio_player],
810
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
811
 
812
  logger.debug("Gradio interface built successfully")
813
  return demo
 
11
  # Standard Library Imports
12
  import asyncio
13
  import time
14
+ from typing import List, Tuple
15
 
16
  # Third-Party Library Imports
17
  import gradio as gr
 
32
  from src.utils import (
33
  create_shuffled_tts_options,
34
  determine_selected_option,
35
+ get_leaderboard_data,
36
  get_random_provider,
37
  submit_voting_results,
38
  validate_character_description_length,
 
43
  class Frontend:
44
  config: Config
45
  db_session_maker: AsyncDBSessionMaker
46
+ _leaderboard_data: List[List[str]]
47
 
48
  def __init__(self, config: Config, db_session_maker: AsyncDBSessionMaker):
49
  self.config = config
50
  self.db_session_maker = db_session_maker
51
 
52
+ async def _update_leaderboard_data(self) -> None:
53
+ """
54
+ Fetches the latest leaderboard data
55
+ """
56
+ latest_leaderboard_data = await get_leaderboard_data(self.db_session_maker)
57
+ self._leaderboard_data = latest_leaderboard_data
58
+
59
  async def _generate_text(self, character_description: str) -> Tuple[gr.Textbox, str]:
60
  """
61
  Validates the character_description and generates text using Anthropic API.
 
229
  character_description,
230
  text,
231
  self.db_session_maker,
 
232
  )
233
  )
234
 
235
  # Build button text to display results
236
+ selected_label = f"{selected_provider} 🏆"
237
  other_label = f"{other_provider}"
238
 
239
  return (
 
275
  gr.update(value=character_description), # Update character description
276
  )
277
 
278
+ async def _refresh_leaderboard(self) -> gr.DataFrame:
279
+ """
280
+ Asynchronously fetches and formats the latest leaderboard data.
281
+
282
+ Returns:
283
+ gr.DataFrame: A Gradio DataFrame update object containing the formatted leaderboard data,
284
+ including rank, provider, model, win rate, and total votes.
285
+ Raises:
286
+ gr.Error: If the leaderboard data cannot be retrieved.
287
+ """
288
+ await self._update_leaderboard_data()
289
+ if not self._leaderboard_data:
290
+ raise gr.Error("Unable to retrieve leaderboard data. Please refresh the page or try again shortly.")
291
+ return gr.update(value=self._leaderboard_data)
292
+
293
  def _disable_ui(self) -> Tuple[
294
  gr.Button,
295
  gr.Dropdown,
 
368
  False, # Reset should_enable_vote_buttons state
369
  )
370
 
371
+ def _build_title_section(self) -> None:
372
  """
373
+ Builds the Title section
374
  """
375
+ gr.HTML(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  """
377
+ <div class="title-container">
378
+ <h1>Expressive TTS Arena</h1>
379
+ <div class="social-links">
380
+ <a
381
+ href="https://discord.com/invite/humeai"
382
+ target="_blank"
383
+ id="discord-link"
384
+ title="Join our Discord"
385
+ aria-label="Join our Discord server"
386
+ ></a>
387
+ <a
388
+ href="https://github.com/HumeAI/expressive-tts-arena"
389
+ target="_blank"
390
+ id="github-link"
391
+ title="View on GitHub"
392
+ aria-label="View project on GitHub"
393
+ ></a>
394
+ </div>
395
+ </div>
396
+ <div class="excerpt-container">
397
+ <p>
398
+ Join the community in evaluating text-to-speech models, and vote for the AI voice that best
399
+ captures the emotion, nuance, and expressiveness of human speech.
400
+ </p>
401
+ </div>
402
  """
403
  )
 
404
 
405
+ def _build_arena_section(self) -> None:
406
  """
407
+ Builds the Arena section
 
408
  """
409
+ # --- UI components ---
410
+ with gr.Row():
411
+ with gr.Column(scale=5):
412
+ gr.HTML(
413
+ """
414
+ <h2 class="tab-header">📋 Instructions</h2>
415
+ <ol>
416
+ <li>
417
+ Select a sample character, or input a custom character description and click
418
+ <strong>"Generate Text"</strong>, to generate your text input.
419
+ </li>
420
+ <li>
421
+ Click the <strong>"Synthesize Speech"</strong> button to synthesize two TTS outputs based on
422
+ your text and character description.
423
+ </li>
424
+ <li>
425
+ Listen to both audio samples to compare their expressiveness.
426
+ </li>
427
+ <li>
428
+ Vote for the most expressive result by clicking either <strong>"Select Option A"</strong> or
429
+ <strong>"Select Option B"</strong>.
430
+ </li>
431
+ </ol>
432
+ """
433
+ )
434
+ randomize_all_button = gr.Button(
435
+ "🎲 Randomize All",
436
+ variant="primary",
437
+ elem_classes="randomize-btn",
438
+ scale=1,
439
  )
440
+
441
+ sample_character_description_dropdown = gr.Dropdown(
442
+ choices=list(constants.SAMPLE_CHARACTER_DESCRIPTIONS.keys()),
443
+ label="Sample Characters",
444
+ info="Generate text with a sample character description.",
445
+ value=None,
446
+ interactive=True,
447
+ )
448
+
449
  with gr.Group():
450
  character_description_input = gr.Textbox(
451
  label="Character Description",
 
456
  show_copy_button=True,
457
  )
458
  generate_text_button = gr.Button("Generate Text", variant="secondary")
459
+
460
  with gr.Group():
461
  text_input = gr.Textbox(
462
  label="Input Text",
 
468
  max_length=constants.CHARACTER_DESCRIPTION_MAX_LENGTH,
469
  show_copy_button=True,
470
  )
 
 
 
 
 
 
 
 
 
471
 
472
+ synthesize_speech_button = gr.Button("Synthesize Speech", variant="primary")
 
 
 
473
 
474
  with gr.Row(equal_height=True):
475
  with gr.Column():
 
504
  text_align="center",
505
  container=False,
506
  )
507
+
508
+ # --- UI state components ---
509
+ # Track character description used for text and voice generation
510
+ character_description_state = gr.State("")
511
+ # Track text used for speech synthesis
512
+ text_state = gr.State("")
513
+ # Track generated text state
514
+ generated_text_state = gr.State("")
515
+ # Track whether text that was used was generated or modified/custom
516
+ text_modified_state = gr.State()
517
+ # Track option map (option A and option B are randomized)
518
+ option_map_state = gr.State({}) # OptionMap state as a dictionary
519
+ # Track whether the user has voted for an option
520
+ vote_submitted_state = gr.State(False)
521
+ # Track whether the vote buttons should be enabled
522
+ should_enable_vote_buttons = gr.State(False)
523
+
524
+ # --- Register event handlers ---
525
+ # "Randomize All" button click event handler chain
526
+ # 1. Disable interactive UI components
527
+ # 2. Reset UI state for audio players and voting results
528
+ # 3. Select random sample character description
529
+ # 4. Generate text
530
+ # 5. Synthesize speech
531
+ # 6. Enable interactive UI components
532
+ randomize_all_button.click(
533
+ fn=self._disable_ui,
534
+ inputs=[],
535
+ outputs=[
536
+ randomize_all_button,
537
+ sample_character_description_dropdown,
538
+ character_description_input,
539
+ generate_text_button,
540
+ text_input,
541
+ synthesize_speech_button,
542
+ vote_button_a,
543
+ vote_button_b,
544
+ ],
545
+ ).then(
546
+ fn=self._reset_voting_ui,
547
+ inputs=[],
548
+ outputs=[
549
+ option_a_audio_player,
550
+ option_b_audio_player,
551
+ vote_button_a,
552
+ vote_button_b,
553
+ vote_result_a,
554
+ vote_result_b,
555
+ option_map_state,
556
+ vote_submitted_state,
557
+ should_enable_vote_buttons,
558
+ ],
559
+ ).then(
560
+ fn=self._randomize_character_description,
561
+ inputs=[],
562
+ outputs=[sample_character_description_dropdown, character_description_input],
563
+ ).then(
564
+ fn=self._generate_text,
565
+ inputs=[character_description_input],
566
+ outputs=[text_input, generated_text_state],
567
+ ).then(
568
+ fn=self._synthesize_speech,
569
+ inputs=[character_description_input, text_input, generated_text_state],
570
+ outputs=[
571
+ option_a_audio_player,
572
+ option_b_audio_player,
573
+ option_map_state,
574
+ text_modified_state,
575
+ text_state,
576
+ character_description_state,
577
+ should_enable_vote_buttons,
578
+ ],
579
+ ).then(
580
+ fn=self._enable_ui,
581
+ inputs=[should_enable_vote_buttons],
582
+ outputs=[
583
+ randomize_all_button,
584
+ sample_character_description_dropdown,
585
+ character_description_input,
586
+ generate_text_button,
587
+ text_input,
588
+ synthesize_speech_button,
589
+ vote_button_a,
590
+ vote_button_b,
591
+ ],
592
  )
593
 
594
+ # "Sample Characters" dropdown select event handler chain:
595
+ # 1. Update Character Description field with sample
596
+ # 2. Disable interactive UI components
597
+ # 3. Generate text
598
+ # 4. Enable interactive UI components
599
+ sample_character_description_dropdown.select(
600
+ fn=lambda choice: constants.SAMPLE_CHARACTER_DESCRIPTIONS.get(choice, ""),
601
+ inputs=[sample_character_description_dropdown],
602
+ outputs=[character_description_input],
603
+ ).then(
604
+ fn=self._disable_ui,
605
+ inputs=[],
606
+ outputs=[
607
+ randomize_all_button,
608
+ sample_character_description_dropdown,
609
+ character_description_input,
610
+ generate_text_button,
611
+ text_input,
612
+ synthesize_speech_button,
613
+ vote_button_a,
614
+ vote_button_b,
615
+ ],
616
+ ).then(
617
+ fn=self._generate_text,
618
+ inputs=[character_description_input],
619
+ outputs=[text_input, generated_text_state],
620
+ ).then(
621
+ fn=self._enable_ui,
622
+ inputs=[should_enable_vote_buttons],
623
+ outputs=[
624
+ randomize_all_button,
625
+ sample_character_description_dropdown,
626
+ character_description_input,
627
+ generate_text_button,
628
+ text_input,
629
+ synthesize_speech_button,
630
+ vote_button_a,
631
+ vote_button_b,
632
+ ],
633
+ )
634
 
635
+ # "Generate Text" button click event handler chain:
636
+ # 1. Disable interactive UI components
637
+ # 2. Generate text
638
+ # 3. Enable interactive UI components
639
+ generate_text_button.click(
640
+ fn=self._disable_ui,
641
+ inputs=[],
642
+ outputs=[
643
+ randomize_all_button,
644
+ sample_character_description_dropdown,
645
+ character_description_input,
646
+ generate_text_button,
647
+ text_input,
648
+ synthesize_speech_button,
649
+ vote_button_a,
650
+ vote_button_b,
651
+ ],
652
+ ).then(
653
+ fn=self._generate_text,
654
+ inputs=[character_description_input],
655
+ outputs=[text_input, generated_text_state],
656
+ ).then(
657
+ fn=self._enable_ui,
658
+ inputs=[should_enable_vote_buttons],
659
+ outputs=[
660
+ randomize_all_button,
661
+ sample_character_description_dropdown,
662
+ character_description_input,
663
+ generate_text_button,
664
+ text_input,
665
+ synthesize_speech_button,
666
+ vote_button_a,
667
+ vote_button_b,
668
+ ],
669
+ )
670
 
671
+ # "Synthesize Speech" button click event handler chain:
672
+ # 1. Disable components in the UI
673
+ # 2. Reset UI state for audio players and voting results
674
+ # 3. Synthesize speech, load audio players, and display vote button
675
+ # 4. Enable interactive components in the UI
676
+ synthesize_speech_button.click(
677
+ fn=self._disable_ui,
678
+ inputs=[],
679
+ outputs=[
680
  randomize_all_button,
681
  sample_character_description_dropdown,
682
  character_description_input,
683
  generate_text_button,
684
  text_input,
685
  synthesize_speech_button,
686
+ vote_button_a,
687
+ vote_button_b,
688
+ ],
689
+ ).then(
690
+ fn=self._reset_voting_ui,
691
+ inputs=[],
692
+ outputs=[
693
  option_a_audio_player,
694
  option_b_audio_player,
695
  vote_button_a,
696
  vote_button_b,
697
  vote_result_a,
698
  vote_result_b,
699
+ option_map_state,
700
+ vote_submitted_state,
701
+ should_enable_vote_buttons,
702
+ ],
703
+ ).then(
704
+ fn=self._synthesize_speech,
705
+ inputs=[character_description_input, text_input, generated_text_state],
706
+ outputs=[
707
+ option_a_audio_player,
708
+ option_b_audio_player,
709
+ option_map_state,
710
+ text_modified_state,
711
+ text_state,
712
+ character_description_state,
713
+ should_enable_vote_buttons,
714
+ ],
715
+ ).then(
716
+ fn=self._enable_ui,
717
+ inputs=[should_enable_vote_buttons],
718
+ outputs=[
719
+ randomize_all_button,
720
+ sample_character_description_dropdown,
721
+ character_description_input,
722
+ generate_text_button,
723
+ text_input,
724
+ synthesize_speech_button,
725
+ vote_button_a,
726
+ vote_button_b,
727
+ ],
728
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
 
730
+ # "Select Option A" button click event handler chain:
731
+ vote_button_a.click(
732
+ fn=lambda _=None: (gr.update(interactive=False), gr.update(interactive=False)),
733
+ inputs=[],
734
+ outputs=[vote_button_a, vote_button_b],
735
+ ).then(
736
+ fn=self._vote,
737
+ inputs=[
738
+ vote_submitted_state,
739
+ option_map_state,
740
+ vote_button_a,
741
+ text_modified_state,
742
+ character_description_state,
743
+ text_state,
744
+ ],
745
+ outputs=[
746
+ vote_submitted_state,
747
+ vote_button_a,
748
+ vote_button_b,
749
+ vote_result_a,
750
+ vote_result_b,
751
+ synthesize_speech_button,
752
+ ],
753
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754
 
755
+ # "Select Option B" button click event handler chain:
756
+ vote_button_b.click(
757
+ fn=lambda _=None: (gr.update(interactive=False), gr.update(interactive=False)),
758
+ inputs=[],
759
+ outputs=[vote_button_a, vote_button_b],
760
+ ).then(
761
+ fn=self._vote,
762
+ inputs=[
763
+ vote_submitted_state,
764
+ option_map_state,
765
+ vote_button_b,
766
+ text_modified_state,
767
+ character_description_state,
768
+ text_state,
769
+ ],
770
+ outputs=[
771
+ vote_submitted_state,
772
+ vote_button_a,
773
+ vote_button_b,
774
+ vote_result_a,
775
+ vote_result_b,
776
+ synthesize_speech_button,
777
+ ],
778
+ )
 
 
 
 
 
 
 
 
 
 
 
779
 
780
+ # Audio Player A stop event handler
781
+ option_a_audio_player.stop(
782
+ # Workaround to play both audio samples back-to-back
783
+ fn=lambda option_map: gr.update(
784
+ value=f"{option_map['option_b']['audio_file_path']}?t={int(time.time())}",
785
+ autoplay=True,
786
+ ),
787
+ inputs=[option_map_state],
788
+ outputs=[option_b_audio_player],
789
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
790
 
791
+ def _build_leaderboard_section(self) -> None:
792
+ """
793
+ Builds the Leaderboard section
794
+ """
795
+ # --- UI components ---
796
+ with gr.Row():
797
+ with gr.Column(scale=5):
798
+ gr.HTML(
799
+ """
800
+ <h2 class="tab-header">🏆 Leaderboard</h2>
801
+ <p>
802
+ This leaderboard presents community voting results for different TTS providers, showing which
803
+ ones users found more expressive and natural-sounding. The win rate reflects how often each
804
+ provider was selected as the preferred option in head-to-head comparisons. Click the refresh
805
+ button to see the most up-to-date voting results.
806
+ </p>
807
+ """
808
+ )
809
+ refresh_button = gr.Button(
810
+ "↻ Refresh",
811
+ variant="primary",
812
+ elem_classes="refresh-btn",
813
+ scale=1,
814
  )
815
 
816
+ with gr.Column(elem_id="leaderboard-table-container"):
817
+ leaderboard_table = gr.DataFrame(
818
+ headers=["Rank", "Provider", "Model", "Win Rate", "Votes"],
819
+ datatype=["html", "html", "html", "html", "html"],
820
+ column_widths=[80, 300, 180, 120, 116],
821
+ value=self._leaderboard_data,
822
+ min_width=680,
823
+ interactive=False,
824
+ render=True,
825
+ elem_id="leaderboard-table"
 
 
 
 
 
 
 
 
 
 
 
 
 
826
  )
827
 
828
+ # --- Register event handlers ---
829
+ # Refresh button click event handler
830
+ refresh_button.click(
831
+ fn=lambda _=None: (gr.update(interactive=False)),
832
+ inputs=[],
833
+ outputs=[refresh_button],
834
+ ).then(
835
+ fn=self._refresh_leaderboard,
836
+ inputs=[],
837
+ outputs=[leaderboard_table]
838
+ ).then(
839
+ fn=lambda _=None: (gr.update(interactive=True)),
840
+ inputs=[],
841
+ outputs=[refresh_button],
842
+ )
843
+
844
+ async def build_gradio_interface(self) -> gr.Blocks:
845
+ """
846
+ Builds and configures the fully constructed Gradio UI layout.
847
+ """
848
+ with gr.Blocks(
849
+ title="Expressive TTS Arena",
850
+ css_paths="static/css/styles.css",
851
+ ) as demo:
852
+ await self._update_leaderboard_data()
853
+ self._build_title_section()
854
+
855
+ with gr.Tabs():
856
+ with gr.TabItem("Arena"):
857
+ self._build_arena_section()
858
+ with gr.TabItem("Leaderboard"):
859
+ self._build_leaderboard_section()
860
 
861
  logger.debug("Gradio interface built successfully")
862
  return demo
src/main.py CHANGED
@@ -86,19 +86,19 @@ async def main():
86
  db_session_maker = init_db(config)
87
 
88
  frontend = Frontend(config, db_session_maker)
89
- demo = frontend.build_gradio_interface()
90
 
91
  app = FastAPI()
92
  app.add_middleware(ResponseModifierMiddleware)
93
 
94
- assets_dir = Path("src/assets")
95
- app.mount("/static", StaticFiles(directory=assets_dir), name="static")
96
 
97
  gr.mount_gradio_app(
98
  app=app,
99
  blocks=demo,
100
  path="/",
101
- allowed_paths=[str(config.audio_dir), "src/assets"]
102
  )
103
 
104
  import uvicorn
 
86
  db_session_maker = init_db(config)
87
 
88
  frontend = Frontend(config, db_session_maker)
89
+ demo = await frontend.build_gradio_interface()
90
 
91
  app = FastAPI()
92
  app.add_middleware(ResponseModifierMiddleware)
93
 
94
+ public_dir = Path("public")
95
+ app.mount("/static", StaticFiles(directory=public_dir), name="static")
96
 
97
  gr.mount_gradio_app(
98
  app=app,
99
  blocks=demo,
100
  path="/",
101
+ allowed_paths=["static"]
102
  )
103
 
104
  import uvicorn
src/utils.py CHANGED
@@ -314,41 +314,54 @@ def _log_voting_results(voting_results: VotingResults) -> None:
314
  logger.info("Voting results:\n%s", json.dumps(voting_results, indent=4))
315
 
316
 
317
- async def _persist_vote(db_session_maker: AsyncDBSessionMaker, voting_results: VotingResults, config: Config) -> None:
318
  """
319
- Asynchronously persist a vote record in the database and handle potential failures.
320
- Designed to work safely in a background task context.
 
 
321
 
322
  Args:
323
  db_session_maker (AsyncDBSessionMaker): A callable that returns a new async database session.
324
- voting_results (VotingResults): A dictionary containing the details of the vote to persist.
325
- config (Config): The application configuration, used to determine environment-specific behavior.
326
 
327
  Returns:
328
- None
329
  """
330
- # Create session
331
  session = db_session_maker()
332
  is_dummy_session = getattr(session, "is_dummy", False)
333
 
334
  if is_dummy_session:
335
- logger.info("Vote record created successfully.")
336
- _log_voting_results(voting_results)
337
  await session.close()
338
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
 
 
 
 
 
 
340
  try:
341
  await crud.create_vote(cast(AsyncSession, session), voting_results)
342
- logger.info("Vote record created successfully.")
343
- if config.app_env == "dev":
344
- _log_voting_results(voting_results)
345
  except Exception as e:
346
- # Log the error with traceback in production, without traceback in dev
347
- logger.error(f"Failed to create vote record: {e}", exc_info=(config.app_env == "prod"))
348
- _log_voting_results(voting_results)
349
  finally:
350
  # Always ensure the session is closed
351
- await session.close()
 
352
 
353
 
354
  async def submit_voting_results(
@@ -358,7 +371,6 @@ async def submit_voting_results(
358
  character_description: str,
359
  text: str,
360
  db_session_maker: AsyncDBSessionMaker,
361
- config: Config,
362
  ) -> None:
363
  """
364
  Asynchronously constructs the voting results dictionary and persists a new vote record.
@@ -395,13 +407,57 @@ async def submit_voting_results(
395
  "is_custom_text": text_modified,
396
  }
397
 
398
- await _persist_vote(db_session_maker, voting_results, config)
399
 
400
  # Catch exceptions at the top level of the background task to prevent unhandled exceptions in background tasks
401
  except Exception as e:
402
  logger.error(f"Background task error in submit_voting_results: {e}", exc_info=True)
403
 
404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  def validate_env_var(var_name: str) -> str:
406
  """
407
  Validates that an environment variable is set and returns its value.
 
314
  logger.info("Voting results:\n%s", json.dumps(voting_results, indent=4))
315
 
316
 
317
+ async def _create_db_session(db_session_maker: AsyncDBSessionMaker) -> AsyncSession:
318
  """
319
+ Creates a new database session using the provided session maker and checks if it's a dummy session.
320
+
321
+ A dummy session might be used in development or testing environments where database operations
322
+ should be simulated but not actually performed.
323
 
324
  Args:
325
  db_session_maker (AsyncDBSessionMaker): A callable that returns a new async database session.
 
 
326
 
327
  Returns:
328
+ AsyncSession: A newly created database session that can be used for database operations.
329
  """
 
330
  session = db_session_maker()
331
  is_dummy_session = getattr(session, "is_dummy", False)
332
 
333
  if is_dummy_session:
 
 
334
  await session.close()
335
+ return None
336
+
337
+ return session
338
+
339
+
340
+ async def _persist_vote(db_session_maker: AsyncDBSessionMaker, voting_results: VotingResults) -> None:
341
+ """
342
+ Asynchronously persist a vote record in the database and handle potential failures.
343
+ Designed to work safely in a background task context.
344
+
345
+ Args:
346
+ db_session_maker (AsyncDBSessionMaker): A callable that returns a new async database session.
347
+ voting_results (VotingResults): A dictionary containing the details of the vote to persist.
348
+ config (Config): The application configuration, used to determine environment-specific behavior.
349
 
350
+ Returns:
351
+ None
352
+ """
353
+ # Create session
354
+ session = await _create_db_session(db_session_maker)
355
+ _log_voting_results(voting_results)
356
  try:
357
  await crud.create_vote(cast(AsyncSession, session), voting_results)
 
 
 
358
  except Exception as e:
359
+ # Log the error with traceback
360
+ logger.error(f"Failed to create vote record: {e}", exc_info=True)
 
361
  finally:
362
  # Always ensure the session is closed
363
+ if session is not None:
364
+ await session.close()
365
 
366
 
367
  async def submit_voting_results(
 
371
  character_description: str,
372
  text: str,
373
  db_session_maker: AsyncDBSessionMaker,
 
374
  ) -> None:
375
  """
376
  Asynchronously constructs the voting results dictionary and persists a new vote record.
 
407
  "is_custom_text": text_modified,
408
  }
409
 
410
+ await _persist_vote(db_session_maker, voting_results)
411
 
412
  # Catch exceptions at the top level of the background task to prevent unhandled exceptions in background tasks
413
  except Exception as e:
414
  logger.error(f"Background task error in submit_voting_results: {e}", exc_info=True)
415
 
416
 
417
+ async def get_leaderboard_data(db_session_maker: AsyncDBSessionMaker) -> List[List[str]]:
418
+ """
419
+ Fetches leaderboard data from voting results database
420
+
421
+ Returns:
422
+ LeaderboardTableEntries: A list of LeaderboardEntry objects containing rank, provider name anchor tag, model
423
+ name anchor tag, win rate, and total votes.
424
+ """
425
+ # Create session
426
+ session = await _create_db_session(db_session_maker)
427
+ try:
428
+ leaderboard_data = await crud.get_leaderboard_stats(cast(AsyncSession, session))
429
+ logger.info("Fetched leaderboard data successfully.")
430
+ # return data formatted for the UI (adds links and styling)
431
+ return [
432
+ [
433
+ f'<p style="text-align: center;">{row[0]}</p>',
434
+ f"""
435
+ <a
436
+ href="{constants.TTS_PROVIDER_LINKS[row[1]]["provider_link"]}"
437
+ target="_blank"
438
+ class="provider-link"
439
+ >{row[1]}</a>
440
+ """,
441
+ f"""<a
442
+ href="{constants.TTS_PROVIDER_LINKS[row[1]]["model_link"]}"
443
+ target="_blank"
444
+ class="provider-link"
445
+ >{row[2]}</a>
446
+ """,
447
+ f'<p style="text-align: center;">{row[3]}</p>',
448
+ f'<p style="text-align: center;">{row[4]}</p>',
449
+ ] for row in leaderboard_data
450
+ ]
451
+ except Exception as e:
452
+ # Log the error with traceback
453
+ logger.error(f"Failed to fetch leaderboard data: {e}", exc_info=True)
454
+ return []
455
+ finally:
456
+ # Always ensure the session is closed
457
+ if session is not None:
458
+ await session.close()
459
+
460
+
461
  def validate_env_var(var_name: str) -> str:
462
  """
463
  Validates that an environment variable is set and returns its value.
{src/assets → static/css}/styles.css RENAMED
@@ -1,47 +1,50 @@
1
- /* Remove Gradio footer */
2
- footer.svelte-1byz9vf {
 
3
  display: none !important;
4
  }
5
 
6
- /* Remove unnecessary padding from HTML containers */
7
- .html-container.svelte-phx28p.padding {
8
- padding: 0px !important;
 
 
 
 
 
 
9
  }
10
 
11
  /* Title container styling */
12
  .title-container {
13
  display: flex;
14
  align-items: center;
15
- height: 40px !important;
16
- overflow: visible !important;
17
  }
18
 
19
- .title-container h1 {
20
- margin: 0 !important;
21
- margin-right: auto !important;
22
  }
23
 
24
  /* Social links container */
25
  .social-links {
26
  display: flex;
27
- align-items: center;
28
  gap: 12px;
29
- margin: 0px 8px;
30
- overflow: visible !important;
31
  }
32
 
33
  /* Social media icons common styles */
34
  #github-link,
35
  #discord-link {
36
- display: inline-block !important;
37
- width: 30px !important;
38
- height: 30px !important;
39
- background-size: cover !important;
40
- background-position: center !important;
41
- background-repeat: no-repeat !important;
42
- transition: transform 0.2s ease-in-out !important;
43
- border-radius: 50% !important;
44
- flex-shrink: 0 !important;
45
  }
46
 
47
  /* Hover effect for social media icons */
@@ -54,27 +57,40 @@ footer.svelte-1byz9vf {
54
  #discord-link {
55
  background-image: url(
56
  'https://assets-global.website-files.com/6257adef93867e50d84d30e2/636e0a6a49cf127bf92de1e2_icon_clyde_blurple_RGB.png'
57
- ) !important;
58
- background-size: 90% !important;
59
  }
60
-
61
  /* GitHub icon specific styling */
62
  #github-link {
63
  background-image: url(
64
  'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'
65
- ) !important;
 
 
 
 
66
  }
67
 
68
  /* Vote result styling */
69
  #vote-result-a textarea,
70
  #vote-result-b textarea {
71
- font-size: 16px !important;
72
- font-weight: bold !important;
73
  }
74
 
75
  /* Winner styling */
76
  #vote-result-a.winner textarea,
77
  #vote-result-b.winner textarea {
78
- background: #EA580C;
79
  color: #FFFFFF;
80
  }
 
 
 
 
 
 
 
 
 
 
1
+ /* Override Gradio vertical scroll bar styling */
2
+ html::-webkit-scrollbar,
3
+ body::-webkit-scrollbar {
4
  display: none !important;
5
  }
6
 
7
+ html,
8
+ body {
9
+ -ms-overflow-style: none !important;
10
+ scrollbar-width: none !important;
11
+ }
12
+
13
+ /* Remove Gradio footer */
14
+ footer.svelte-1byz9vf {
15
+ display: none !important;
16
  }
17
 
18
  /* Title container styling */
19
  .title-container {
20
  display: flex;
21
  align-items: center;
22
+ justify-content: space-between;
 
23
  }
24
 
25
+
26
+ .excerpt-container {
27
+ margin: 8px 0;
28
  }
29
 
30
  /* Social links container */
31
  .social-links {
32
  display: flex;
 
33
  gap: 12px;
 
 
34
  }
35
 
36
  /* Social media icons common styles */
37
  #github-link,
38
  #discord-link {
39
+ display: inline-block;
40
+ width: 30px;
41
+ height: 30px;
42
+ background-size: cover;
43
+ background-position: center;
44
+ background-repeat: no-repeat;
45
+ transition: transform 0.2s ease-in-out;
46
+ border-radius: 50%;
47
+ flex-shrink: 0;
48
  }
49
 
50
  /* Hover effect for social media icons */
 
57
  #discord-link {
58
  background-image: url(
59
  'https://assets-global.website-files.com/6257adef93867e50d84d30e2/636e0a6a49cf127bf92de1e2_icon_clyde_blurple_RGB.png'
60
+ );
61
+ background-size: 90%;
62
  }
63
+
64
  /* GitHub icon specific styling */
65
  #github-link {
66
  background-image: url(
67
  'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'
68
+ );
69
+ }
70
+
71
+ .tab-header {
72
+ font-size: 18px;
73
  }
74
 
75
  /* Vote result styling */
76
  #vote-result-a textarea,
77
  #vote-result-b textarea {
78
+ font-size: 16px;
79
+ font-weight: bold;
80
  }
81
 
82
  /* Winner styling */
83
  #vote-result-a.winner textarea,
84
  #vote-result-b.winner textarea {
85
+ background: #F97316;
86
  color: #FFFFFF;
87
  }
88
+
89
+ .provider-link {
90
+ color: #F97316;
91
+ text-decoration: underline;
92
+ }
93
+
94
+ .provider-link:hover {
95
+ color: #EA580C;
96
+ }