GitHub Actions commited on
Commit
f1a0148
·
1 Parent(s): 16a9bac

Sync from GitHub repo

Browse files
.env.example ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SECRET_KEY=your-secret-key-here
2
+ OAUTH_CLIENT_ID=your-huggingface-client-id
3
+ OAUTH_CLIENT_SECRET=your-huggingface-client-secret
4
+ DATABASE_URI=sqlite:///tts_arena.db
5
+
6
+ FAL_KEY=
7
+ PLAY_USERID=
8
+ PLAY_SECRETKEY=
9
+
10
+ TURNSTILE_ENABLED=
11
+ TURNSTILE_SITE_KEY=
12
+ TURNSTILE_SECRET_KEY=
13
+ TURNSTILE_TIMEOUT_HOURS=24
14
+
15
+ HF_TOKEN=your-huggingface-token-here
.github/workflows/sync-to-hf.yaml ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Space
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ sync-to-hf:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout repository
14
+ uses: actions/checkout@v3
15
+
16
+ - name: Set up Git
17
+ run: |
18
+ git config --global user.email "[email protected]"
19
+ git config --global user.name "GitHub Actions"
20
+
21
+ - name: Push to Hugging Face Space
22
+ env:
23
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
24
+ run: |
25
+ # Replace these with your HF username and space name
26
+ HF_USERNAME="TTS-AGI"
27
+ SPACE_NAME="TTS-Arena-V2"
28
+
29
+ # Clone the HF space repo
30
+ git clone https://$HF_USERNAME:[email protected]/spaces/$HF_USERNAME/$SPACE_NAME hf-space
31
+
32
+ # Copy all files to the space repo (except .git and hf-space folder)
33
+ rsync -av --exclude='.git' --exclude='hf-space' ./ hf-space/
34
+
35
+ # Rename SPACES_README.md to README.md for Hugging Face
36
+ if [ -f hf-space/SPACES_README.md ]; then
37
+ mv hf-space/SPACES_README.md hf-space/README.md
38
+ fi
39
+
40
+ cd hf-space
41
+ git add .
42
+ git commit -m "Sync from GitHub repo" || echo "No changes to commit"
43
+ git push
.gitignore CHANGED
@@ -1,12 +1,8 @@
1
- # Byte-compiled / optimized / DLL files
2
  __pycache__/
3
  *.py[cod]
4
  *$py.class
5
-
6
- # C extensions
7
  *.so
8
-
9
- # Distribution / packaging
10
  .Python
11
  build/
12
  develop-eggs/
@@ -20,114 +16,11 @@ parts/
20
  sdist/
21
  var/
22
  wheels/
23
- share/python-wheels/
24
  *.egg-info/
25
  .installed.cfg
26
  *.egg
27
- MANIFEST
28
-
29
- # PyInstaller
30
- # Usually these files are written by a python script from a template
31
- # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
- *.manifest
33
- *.spec
34
-
35
- # Installer logs
36
- pip-log.txt
37
- pip-delete-this-directory.txt
38
-
39
- # Unit test / coverage reports
40
- htmlcov/
41
- .tox/
42
- .nox/
43
- .coverage
44
- .coverage.*
45
- .cache
46
- nosetests.xml
47
- coverage.xml
48
- *.cover
49
- *.py,cover
50
- .hypothesis/
51
- .pytest_cache/
52
- cover/
53
-
54
- # Translations
55
- *.mo
56
- *.pot
57
-
58
- # Django stuff:
59
- *.log
60
- local_settings.py
61
- db.sqlite3
62
- db.sqlite3-journal
63
-
64
- # Flask stuff:
65
- instance/
66
- .webassets-cache
67
-
68
- # Scrapy stuff:
69
- .scrapy
70
-
71
- # Sphinx documentation
72
- docs/_build/
73
-
74
- # PyBuilder
75
- .pybuilder/
76
- target/
77
-
78
- # Jupyter Notebook
79
- .ipynb_checkpoints
80
-
81
- # IPython
82
- profile_default/
83
- ipython_config.py
84
-
85
- # pyenv
86
- # For a library or package, you might want to ignore these files since the code is
87
- # intended to run in multiple environments; otherwise, check them in:
88
- # .python-version
89
-
90
- # pipenv
91
- # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
- # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
- # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
- # install all needed dependencies.
95
- #Pipfile.lock
96
-
97
- # UV
98
- # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
- # This is especially recommended for binary packages to ensure reproducibility, and is more
100
- # commonly ignored for libraries.
101
- #uv.lock
102
 
103
- # poetry
104
- # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
- # This is especially recommended for binary packages to ensure reproducibility, and is more
106
- # commonly ignored for libraries.
107
- # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
- #poetry.lock
109
-
110
- # pdm
111
- # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112
- #pdm.lock
113
- # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114
- # in version control.
115
- # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116
- .pdm.toml
117
- .pdm-python
118
- .pdm-build/
119
-
120
- # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121
- __pypackages__/
122
-
123
- # Celery stuff
124
- celerybeat-schedule
125
- celerybeat.pid
126
-
127
- # SageMath parsed files
128
- *.sage.py
129
-
130
- # Environments
131
  .env
132
  .venv
133
  env/
@@ -135,42 +28,23 @@ venv/
135
  ENV/
136
  env.bak/
137
  venv.bak/
 
138
 
139
- # Spyder project settings
140
- .spyderproject
141
- .spyproject
142
-
143
- # Rope project settings
144
- .ropeproject
145
-
146
- # mkdocs documentation
147
- /site
148
-
149
- # mypy
150
- .mypy_cache/
151
- .dmypy.json
152
- dmypy.json
153
-
154
- # Pyre type checker
155
- .pyre/
156
-
157
- # pytype static type analyzer
158
- .pytype/
159
-
160
- # Cython debug symbols
161
- cython_debug/
162
-
163
- # PyCharm
164
- # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165
- # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166
- # and can be added to the global gitignore or merged into this file. For a more nuclear
167
- # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168
- #.idea/
169
-
170
- # Ruff stuff:
171
- .ruff_cache/
172
-
173
- # PyPI configuration file
174
- .pypirc
175
-
176
- *.db
 
1
+ # Python
2
  __pycache__/
3
  *.py[cod]
4
  *$py.class
 
 
5
  *.so
 
 
6
  .Python
7
  build/
8
  develop-eggs/
 
16
  sdist/
17
  var/
18
  wheels/
 
19
  *.egg-info/
20
  .installed.cfg
21
  *.egg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
+ # Environment and local development
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  .env
25
  .venv
26
  env/
 
28
  ENV/
29
  env.bak/
30
  venv.bak/
31
+ .flaskenv
32
 
33
+ # Database
34
+ instance/
35
+ *.db
36
+ *.sqlite
37
+ *.sqlite3
38
+
39
+ # IDE
40
+ .idea/
41
+ .vscode/
42
+ *.swp
43
+ *.swo
44
+
45
+ # OS
46
+ .DS_Store
47
+ Thumbs.db
48
+
49
+ # Uploads
50
+ static/temp_audio
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
COPYING ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This project is dual-licensed under the MIT license and the Apache 2.0 license. See the LICENSE.MIT and LICENSE.APACHE files respectively for details.
2
+
3
+ Copyright 2025 mrfakename
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
LICENSE.APACHE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
LICENSE.MIT ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 mrfakename
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,29 +1,15 @@
1
  ---
2
- title: TTS Arena
3
- sdk: gradio
4
- app_file: app.py
5
- license: zlib
6
- tags:
7
- - arena
8
  emoji: 🏆
9
  colorFrom: blue
10
  colorTo: blue
11
- pinned: true
12
- header: mini
13
- sdk_version: 5.25.2
14
- short_description: Vote on the latest TTS models!
15
- ---
16
-
17
- # TTS Arena
18
-
19
- The codebase for TTS Arena v2.
20
-
21
- The TTS Arena is a Gradio app with several components. Please refer to the `app` directory for more information.
22
 
23
- ## Running the app
 
24
 
25
- ```bash
26
- RUNNING_LOCALLY=1 python app.py
27
- ```
28
 
29
- You must set the `RUNNING_LOCALLY` environment variable to `1` when running the app locally. This prevents it from syncing with the database.
 
1
  ---
2
+ title: TTS Arena V2 (Beta)
 
 
 
 
 
3
  emoji: 🏆
4
  colorFrom: blue
5
  colorTo: blue
6
+ sdk: gradio
7
+ app_file: app.py
8
+ short_description: (Private) Vote on the latest TTS models!
 
 
 
 
 
 
 
 
9
 
10
+ hf_oauth: true
11
+ ---
12
 
13
+ Please see the [GitHub repo](https://github.com/TTS-AGI/TTS-Arena-V2) for information.
 
 
14
 
15
+ Join the [Discord server](https://discord.gg/HB8fMR6GTr) for updates and support.
TURNSTILE.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cloudflare Turnstile Integration
2
+
3
+ TTS Arena supports Cloudflare Turnstile for bot protection. This guide explains how to set up and configure Turnstile for your deployment.
4
+
5
+ ## What is Cloudflare Turnstile?
6
+
7
+ Cloudflare Turnstile is a CAPTCHA alternative that provides protection against bots and malicious traffic while maintaining a user-friendly experience. Unlike traditional CAPTCHAs, Turnstile uses a variety of signals to detect bots without forcing legitimate users to solve frustrating puzzles.
8
+
9
+ ## Setup Instructions
10
+
11
+ ### 1. Register for Cloudflare Turnstile
12
+
13
+ 1. Create a Cloudflare account or log in to your existing account
14
+ 2. Go to the [Turnstile dashboard](https://dash.cloudflare.com/?to=/:account/turnstile)
15
+ 3. Click "Add Site" and follow the instructions
16
+ 4. Create a new site key
17
+ - Choose "Managed" or "Invisible" mode (Managed is recommended for better balance of security and user experience)
18
+ - Set an appropriate domain policy
19
+ - Create the site key
20
+
21
+ Once created, you'll receive a **Site Key** (public) and **Secret Key** (private).
22
+
23
+ ### 2. Configure Environment Variables
24
+
25
+ Add the following environment variables to your deployment:
26
+
27
+ ```
28
+ TURNSTILE_ENABLED=true
29
+ TURNSTILE_SITE_KEY=your_site_key_here
30
+ TURNSTILE_SECRET_KEY=your_secret_key_here
31
+ TURNSTILE_TIMEOUT_HOURS=24
32
+ ```
33
+
34
+ | Variable | Description |
35
+ |----------|-------------|
36
+ | `TURNSTILE_ENABLED` | Set to `true` to enable Turnstile protection |
37
+ | `TURNSTILE_SITE_KEY` | Your Cloudflare Turnstile site key |
38
+ | `TURNSTILE_SECRET_KEY` | Your Cloudflare Turnstile secret key |
39
+ | `TURNSTILE_TIMEOUT_HOURS` | How often users need to verify (default: 24 hours) |
40
+
41
+ ### 3. Implementation Details
42
+
43
+ When Turnstile is enabled:
44
+ - All routes and API endpoints require Turnstile verification
45
+ - Users are redirected to a verification page when they first visit
46
+ - Verification status is stored in the session
47
+ - Re-verification is required after the timeout period
48
+ - API requests receive a 403 error if not verified
49
+
50
+ ## Customization
51
+
52
+ The Turnstile verification page uses the same styling as the main application, providing a seamless user experience. You can customize the appearance by modifying `templates/turnstile.html`.
53
+
54
+ ## Troubleshooting
55
+
56
+ - **Verification Loops**: If users get stuck in verification loops, check that cookies are being properly stored (ensure proper cookie settings and no browser extensions blocking cookies)
57
+ - **API Errors**: If API clients receive 403 errors, they need to implement Turnstile verification
58
+ - **Missing Environment Variables**: Ensure all required environment variables are set correctly
admin.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, current_app, jsonify, request, redirect, url_for, flash
2
+ from models import db, User, Model, Vote, EloHistory, ModelType
3
+ from auth import admin_required
4
+ from sqlalchemy import func, desc, extract
5
+ from datetime import datetime, timedelta
6
+ import json
7
+ import os
8
+
9
+ admin = Blueprint("admin", __name__, url_prefix="/admin")
10
+
11
+ @admin.route("/")
12
+ @admin_required
13
+ def index():
14
+ """Admin dashboard homepage"""
15
+ # Get count statistics
16
+ stats = {
17
+ "total_users": User.query.count(),
18
+ "total_votes": Vote.query.count(),
19
+ "tts_votes": Vote.query.filter_by(model_type=ModelType.TTS).count(),
20
+ "conversational_votes": Vote.query.filter_by(model_type=ModelType.CONVERSATIONAL).count(),
21
+ "tts_models": Model.query.filter_by(model_type=ModelType.TTS).count(),
22
+ "conversational_models": Model.query.filter_by(model_type=ModelType.CONVERSATIONAL).count(),
23
+ }
24
+
25
+ # Get recent votes
26
+ recent_votes = Vote.query.order_by(Vote.vote_date.desc()).limit(10).all()
27
+
28
+ # Get recent users
29
+ recent_users = User.query.order_by(User.join_date.desc()).limit(10).all()
30
+
31
+ # Get daily votes for the past 30 days
32
+ thirty_days_ago = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
33
+
34
+ daily_votes = db.session.query(
35
+ func.date(Vote.vote_date).label('date'),
36
+ func.count().label('count')
37
+ ).filter(Vote.vote_date >= thirty_days_ago).group_by(
38
+ func.date(Vote.vote_date)
39
+ ).order_by(func.date(Vote.vote_date)).all()
40
+
41
+ # Generate a complete list of dates for the past 30 days
42
+ date_list = []
43
+ current_date = datetime.utcnow()
44
+ for i in range(30, -1, -1):
45
+ date_list.append((current_date - timedelta(days=i)).date())
46
+
47
+ # Create a dictionary with actual vote counts
48
+ vote_counts = {day.date: day.count for day in daily_votes}
49
+
50
+ # Build complete datasets including days with zero votes
51
+ formatted_dates = [date.strftime("%Y-%m-%d") for date in date_list]
52
+ vote_counts_list = [vote_counts.get(date, 0) for date in date_list]
53
+
54
+ daily_votes_data = {
55
+ "labels": formatted_dates,
56
+ "counts": vote_counts_list
57
+ }
58
+
59
+ # Get top models
60
+ top_tts_models = Model.query.filter_by(
61
+ model_type=ModelType.TTS
62
+ ).order_by(Model.current_elo.desc()).limit(5).all()
63
+
64
+ top_conversational_models = Model.query.filter_by(
65
+ model_type=ModelType.CONVERSATIONAL
66
+ ).order_by(Model.current_elo.desc()).limit(5).all()
67
+
68
+ return render_template(
69
+ "admin/index.html",
70
+ stats=stats,
71
+ recent_votes=recent_votes,
72
+ recent_users=recent_users,
73
+ daily_votes_data=json.dumps(daily_votes_data),
74
+ top_tts_models=top_tts_models,
75
+ top_conversational_models=top_conversational_models
76
+ )
77
+
78
+ @admin.route("/models")
79
+ @admin_required
80
+ def models():
81
+ """Manage models"""
82
+ tts_models = Model.query.filter_by(model_type=ModelType.TTS).order_by(Model.name).all()
83
+ conversational_models = Model.query.filter_by(model_type=ModelType.CONVERSATIONAL).order_by(Model.name).all()
84
+
85
+ return render_template(
86
+ "admin/models.html",
87
+ tts_models=tts_models,
88
+ conversational_models=conversational_models
89
+ )
90
+
91
+
92
+ @admin.route("/model/<model_id>", methods=["GET", "POST"])
93
+ @admin_required
94
+ def edit_model(model_id):
95
+ """Edit a model"""
96
+ model = Model.query.get_or_404(model_id)
97
+
98
+ if request.method == "POST":
99
+ model.name = request.form.get("name")
100
+ model.is_active = "is_active" in request.form
101
+ model.is_open = "is_open" in request.form
102
+ model.model_url = request.form.get("model_url")
103
+
104
+ db.session.commit()
105
+ flash(f"Model '{model.name}' updated successfully", "success")
106
+ return redirect(url_for("admin.models"))
107
+
108
+ return render_template("admin/edit_model.html", model=model)
109
+
110
+ @admin.route("/users")
111
+ @admin_required
112
+ def users():
113
+ """Manage users"""
114
+ users = User.query.order_by(User.username).all()
115
+ admin_users = os.getenv("ADMIN_USERS", "").split(",")
116
+ admin_users = [username.strip() for username in admin_users]
117
+
118
+ return render_template("admin/users.html", users=users, admin_users=admin_users)
119
+
120
+ @admin.route("/user/<int:user_id>")
121
+ @admin_required
122
+ def user_detail(user_id):
123
+ """View user details"""
124
+ user = User.query.get_or_404(user_id)
125
+
126
+ # Get user votes
127
+ recent_votes = Vote.query.filter_by(user_id=user_id).order_by(Vote.vote_date.desc()).limit(20).all()
128
+
129
+ # Get vote statistics
130
+ tts_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.TTS).count()
131
+ conversational_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.CONVERSATIONAL).count()
132
+
133
+ # Get favorite models (most chosen)
134
+ favorite_models = db.session.query(
135
+ Vote.model_chosen,
136
+ Model.name,
137
+ func.count().label('count')
138
+ ).join(
139
+ Model, Vote.model_chosen == Model.id
140
+ ).filter(
141
+ Vote.user_id == user_id
142
+ ).group_by(
143
+ Vote.model_chosen, Model.name
144
+ ).order_by(
145
+ desc('count')
146
+ ).limit(5).all()
147
+
148
+ return render_template(
149
+ "admin/user_detail.html",
150
+ user=user,
151
+ recent_votes=recent_votes,
152
+ tts_votes=tts_votes,
153
+ conversational_votes=conversational_votes,
154
+ favorite_models=favorite_models,
155
+ total_votes=tts_votes + conversational_votes
156
+ )
157
+
158
+ @admin.route("/votes")
159
+ @admin_required
160
+ def votes():
161
+ """View recent votes"""
162
+ page = request.args.get('page', 1, type=int)
163
+ per_page = 50
164
+
165
+ # Get votes with pagination
166
+ votes_pagination = Vote.query.order_by(
167
+ Vote.vote_date.desc()
168
+ ).paginate(page=page, per_page=per_page)
169
+
170
+ return render_template(
171
+ "admin/votes.html",
172
+ votes=votes_pagination.items,
173
+ pagination=votes_pagination
174
+ )
175
+
176
+ @admin.route("/statistics")
177
+ @admin_required
178
+ def statistics():
179
+ """View detailed statistics"""
180
+ # Get daily votes for the past 30 days by model type
181
+ thirty_days_ago = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
182
+
183
+ tts_daily_votes = db.session.query(
184
+ func.date(Vote.vote_date).label('date'),
185
+ func.count().label('count')
186
+ ).filter(
187
+ Vote.vote_date >= thirty_days_ago,
188
+ Vote.model_type == ModelType.TTS
189
+ ).group_by(
190
+ func.date(Vote.vote_date)
191
+ ).order_by(func.date(Vote.vote_date)).all()
192
+
193
+ conv_daily_votes = db.session.query(
194
+ func.date(Vote.vote_date).label('date'),
195
+ func.count().label('count')
196
+ ).filter(
197
+ Vote.vote_date >= thirty_days_ago,
198
+ Vote.model_type == ModelType.CONVERSATIONAL
199
+ ).group_by(
200
+ func.date(Vote.vote_date)
201
+ ).order_by(func.date(Vote.vote_date)).all()
202
+
203
+ # Monthly new users
204
+ monthly_users = db.session.query(
205
+ extract('year', User.join_date).label('year'),
206
+ extract('month', User.join_date).label('month'),
207
+ func.count().label('count')
208
+ ).group_by(
209
+ 'year', 'month'
210
+ ).order_by('year', 'month').all()
211
+
212
+ # Generate a complete list of dates for the past 30 days
213
+ date_list = []
214
+ current_date = datetime.utcnow()
215
+ for i in range(30, -1, -1):
216
+ date_list.append((current_date - timedelta(days=i)).date())
217
+
218
+ # Create dictionaries with actual vote counts
219
+ tts_vote_counts = {day.date: day.count for day in tts_daily_votes}
220
+ conv_vote_counts = {day.date: day.count for day in conv_daily_votes}
221
+
222
+ # Format dates consistently for charts
223
+ formatted_dates = [date.strftime("%Y-%m-%d") for date in date_list]
224
+
225
+ # Build complete datasets including days with zero votes
226
+ tts_counts = [tts_vote_counts.get(date, 0) for date in date_list]
227
+ conv_counts = [conv_vote_counts.get(date, 0) for date in date_list]
228
+
229
+ # Generate all month/year combinations for the past 12 months
230
+ current_date = datetime.utcnow()
231
+ month_list = []
232
+ for i in range(11, -1, -1):
233
+ past_date = current_date - timedelta(days=i*30) # Approximate
234
+ month_list.append((past_date.year, past_date.month))
235
+
236
+ # Create a dictionary with actual user counts
237
+ user_counts = {(record.year, record.month): record.count for record in monthly_users}
238
+
239
+ # Build complete monthly datasets including months with zero new users
240
+ monthly_labels = [f"{month}/{year}" for year, month in month_list]
241
+ monthly_counts = [user_counts.get((year, month), 0) for year, month in month_list]
242
+
243
+ # Model performance over time
244
+ top_models = Model.query.order_by(Model.match_count.desc()).limit(5).all()
245
+
246
+ # Get first and last timestamp to create a consistent timeline
247
+ earliest = datetime.utcnow() - timedelta(days=30) # Default to 30 days ago
248
+ latest = datetime.utcnow() # Default to now
249
+
250
+ # Find actual earliest and latest timestamps across all models
251
+ has_elo_history = False
252
+ for model in top_models:
253
+ first = EloHistory.query.filter_by(model_id=model.id).order_by(EloHistory.timestamp).first()
254
+ last = EloHistory.query.filter_by(model_id=model.id).order_by(EloHistory.timestamp.desc()).first()
255
+
256
+ if first and last:
257
+ has_elo_history = True
258
+ if first.timestamp < earliest:
259
+ earliest = first.timestamp
260
+ if last.timestamp > latest:
261
+ latest = last.timestamp
262
+
263
+ # If no history was found, use a default range of the last 30 days
264
+ if not has_elo_history:
265
+ earliest = datetime.utcnow() - timedelta(days=30)
266
+ latest = datetime.utcnow()
267
+
268
+ # Make sure the date range is valid (earliest before latest)
269
+ if earliest > latest:
270
+ earliest = latest - timedelta(days=30)
271
+
272
+ # Generate a list of dates for the ELO history timeline
273
+ # Using 1-day intervals for a smoother chart
274
+ elo_dates = []
275
+ current = earliest
276
+ while current <= latest:
277
+ elo_dates.append(current.date())
278
+ current += timedelta(days=1)
279
+
280
+ # Format dates consistently
281
+ formatted_elo_dates = [date.strftime("%Y-%m-%d") for date in elo_dates]
282
+
283
+ model_history = {}
284
+
285
+ # Initialize empty data for all top models
286
+ for model in top_models:
287
+ model_history[model.name] = {
288
+ "timestamps": formatted_elo_dates,
289
+ "scores": [None] * len(formatted_elo_dates) # Initialize with None values
290
+ }
291
+
292
+ history = EloHistory.query.filter_by(
293
+ model_id=model.id
294
+ ).order_by(EloHistory.timestamp).all()
295
+
296
+ if history:
297
+ # Create a dictionary mapping dates to scores
298
+ history_dict = {}
299
+ for h in history:
300
+ date_key = h.timestamp.date().strftime("%Y-%m-%d")
301
+ history_dict[date_key] = h.elo_score
302
+
303
+ # Fill in missing dates with the previous score
304
+ last_score = model.current_elo # Default to current ELO if no history
305
+ scores = []
306
+
307
+ for date in formatted_elo_dates:
308
+ if date in history_dict:
309
+ last_score = history_dict[date]
310
+ scores.append(last_score)
311
+
312
+ model_history[model.name]["scores"] = scores
313
+ else:
314
+ # If no history, use the current Elo for all dates
315
+ model_history[model.name]["scores"] = [model.current_elo] * len(formatted_elo_dates)
316
+
317
+ chart_data = {
318
+ "dailyVotes": {
319
+ "labels": formatted_dates,
320
+ "ttsCounts": tts_counts,
321
+ "convCounts": conv_counts
322
+ },
323
+ "monthlyUsers": {
324
+ "labels": monthly_labels,
325
+ "counts": monthly_counts
326
+ },
327
+ "modelHistory": model_history
328
+ }
329
+
330
+ return render_template(
331
+ "admin/statistics.html",
332
+ chart_data=json.dumps(chart_data)
333
+ )
334
+
335
+ @admin.route("/activity")
336
+ @admin_required
337
+ def activity():
338
+ """View recent text generations"""
339
+ # Check if we have any active sessions from app.py
340
+ tts_session_count = 0
341
+ conversational_session_count = 0
342
+
343
+ # Access global variables from app.py through current_app
344
+ if hasattr(current_app, 'tts_sessions'):
345
+ tts_session_count = len(current_app.tts_sessions)
346
+ else: # Try to access through app module
347
+ from app import tts_sessions
348
+ tts_session_count = len(tts_sessions)
349
+
350
+ if hasattr(current_app, 'conversational_sessions'):
351
+ conversational_session_count = len(current_app.conversational_sessions)
352
+ else: # Try to access through app module
353
+ from app import conversational_sessions
354
+ conversational_session_count = len(conversational_sessions)
355
+
356
+ # Get recent votes which represent completed generations
357
+ recent_tts_votes = Vote.query.filter_by(
358
+ model_type=ModelType.TTS
359
+ ).order_by(Vote.vote_date.desc()).limit(20).all()
360
+
361
+ recent_conv_votes = Vote.query.filter_by(
362
+ model_type=ModelType.CONVERSATIONAL
363
+ ).order_by(Vote.vote_date.desc()).limit(20).all()
364
+
365
+ # Get votes per hour for the last 24 hours
366
+ current_time = datetime.utcnow()
367
+ last_24h = current_time.replace(minute=0, second=0, microsecond=0) - timedelta(hours=24)
368
+
369
+ # Use SQLite-compatible date formatting
370
+ hourly_votes = db.session.query(
371
+ func.strftime('%Y-%m-%d %H:00', Vote.vote_date).label('hour'),
372
+ func.count().label('count')
373
+ ).filter(
374
+ Vote.vote_date >= last_24h
375
+ ).group_by('hour').order_by('hour').all()
376
+
377
+ # Generate all hours for the past 24 hours with correct hour formatting
378
+ hour_list = []
379
+ for i in range(24, -1, -1):
380
+ # Calculate the hour time and truncate to hour
381
+ hour_time = current_time - timedelta(hours=i)
382
+ hour_time = hour_time.replace(minute=0, second=0, microsecond=0)
383
+ hour_list.append(hour_time.strftime('%Y-%m-%d %H:00'))
384
+
385
+ # Create a dictionary with actual vote counts
386
+ vote_counts = {hour.hour: hour.count for hour in hourly_votes}
387
+
388
+ # Build complete hourly datasets including hours with zero votes
389
+ hourly_data = {
390
+ "labels": hour_list,
391
+ "counts": [vote_counts.get(hour, 0) for hour in hour_list]
392
+ }
393
+
394
+ return render_template(
395
+ "admin/activity.html",
396
+ tts_session_count=tts_session_count,
397
+ conversational_session_count=conversational_session_count,
398
+ recent_tts_votes=recent_tts_votes,
399
+ recent_conv_votes=recent_conv_votes,
400
+ hourly_data=json.dumps(hourly_data)
401
+ )
app.py CHANGED
@@ -1,4 +1,1067 @@
1
- from app.ui import app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  if __name__ == "__main__":
4
- app.queue(default_concurrency_limit=50, api_open=False).launch(show_api=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from huggingface_hub import HfApi, hf_hub_download
3
+ from apscheduler.schedulers.background import BackgroundScheduler
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from datetime import datetime
6
+
7
+ year = datetime.now().year
8
+ month = datetime.now().month
9
+
10
+ # Check if running in a Huggin Face Space
11
+ IS_SPACES = False
12
+ if os.getenv("SPACE_REPO_NAME"):
13
+ print("Running in a Hugging Face Space 🤗")
14
+ IS_SPACES = True
15
+
16
+ # Setup database sync for HF Spaces
17
+ if not os.path.exists("instance/tts_arena.db"):
18
+ os.makedirs("instance", exist_ok=True)
19
+ try:
20
+ print("Database not found, downloading from HF dataset...")
21
+ hf_hub_download(
22
+ repo_id="TTS-AGI/database-arena-v2",
23
+ filename="tts_arena.db",
24
+ repo_type="dataset",
25
+ local_dir="instance",
26
+ token=os.getenv("HF_TOKEN"),
27
+ )
28
+ print("Database downloaded successfully ✅")
29
+ except Exception as e:
30
+ print(f"Error downloading database from HF dataset: {str(e)} ⚠️")
31
+
32
+ from flask import (
33
+ Flask,
34
+ render_template,
35
+ g,
36
+ request,
37
+ jsonify,
38
+ send_file,
39
+ redirect,
40
+ url_for,
41
+ session,
42
+ abort,
43
+ )
44
+ from flask_login import LoginManager, current_user
45
+ from models import *
46
+ from auth import auth, init_oauth, is_admin
47
+ from admin import admin
48
+ import os
49
+ from dotenv import load_dotenv
50
+ from flask_limiter import Limiter
51
+ from flask_limiter.util import get_remote_address
52
+ import uuid
53
+ import tempfile
54
+ import shutil
55
+ from tts import predict_tts
56
+ import random
57
+ import json
58
+ from datetime import datetime, timedelta
59
+ from flask_migrate import Migrate
60
+ import requests
61
+ import functools
62
+ import time # Added for potential retries
63
+
64
+
65
+ # Load environment variables
66
+ if not IS_SPACES:
67
+ load_dotenv() # Only load .env if not running in a Hugging Face Space
68
+
69
+ app = Flask(__name__)
70
+ app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", os.urandom(24))
71
+ app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv(
72
+ "DATABASE_URI", "sqlite:///tts_arena.db"
73
+ )
74
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
75
+ app.config["SESSION_COOKIE_SECURE"] = True
76
+ app.config["SESSION_COOKIE_SAMESITE"] = (
77
+ "None" if IS_SPACES else "Lax"
78
+ ) # HF Spaces uses iframes to load the app, so we need to set SAMESITE to None
79
+ app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) # Set to desired duration
80
+
81
+ # Force HTTPS when running in HuggingFace Spaces
82
+ if IS_SPACES:
83
+ app.config["PREFERRED_URL_SCHEME"] = "https"
84
+
85
+ # Cloudflare Turnstile settings
86
+ app.config["TURNSTILE_ENABLED"] = (
87
+ os.getenv("TURNSTILE_ENABLED", "False").lower() == "true"
88
+ )
89
+ app.config["TURNSTILE_SITE_KEY"] = os.getenv("TURNSTILE_SITE_KEY", "")
90
+ app.config["TURNSTILE_SECRET_KEY"] = os.getenv("TURNSTILE_SECRET_KEY", "")
91
+ app.config["TURNSTILE_VERIFY_URL"] = (
92
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify"
93
+ )
94
+
95
+ migrate = Migrate(app, db)
96
+
97
+ # Initialize extensions
98
+ db.init_app(app)
99
+ login_manager = LoginManager()
100
+ login_manager.init_app(app)
101
+ login_manager.login_view = "auth.login"
102
+
103
+ # Initialize OAuth
104
+ init_oauth(app)
105
+
106
+ # Configure rate limits
107
+ limiter = Limiter(
108
+ app=app,
109
+ key_func=get_remote_address,
110
+ default_limits=["200 per day", "50 per hour"],
111
+ storage_uri="memory://",
112
+ )
113
+
114
+ # Create temp directory for audio files
115
+ TEMP_AUDIO_DIR = os.path.join(tempfile.gettempdir(), "tts_arena_audio")
116
+ os.makedirs(TEMP_AUDIO_DIR, exist_ok=True)
117
+
118
+ # Store active TTS sessions
119
+ app.tts_sessions = {}
120
+ tts_sessions = app.tts_sessions
121
+
122
+ # Store active conversational sessions
123
+ app.conversational_sessions = {}
124
+ conversational_sessions = app.conversational_sessions
125
+
126
+ # Register blueprints
127
+ app.register_blueprint(auth, url_prefix="/auth")
128
+ app.register_blueprint(admin)
129
+
130
+
131
+ @login_manager.user_loader
132
+ def load_user(user_id):
133
+ return User.query.get(int(user_id))
134
+
135
+
136
+ @app.before_request
137
+ def before_request():
138
+ g.user = current_user
139
+ g.is_admin = is_admin(current_user)
140
+
141
+ # Ensure HTTPS for HuggingFace Spaces environment
142
+ if IS_SPACES and request.headers.get("X-Forwarded-Proto") == "http":
143
+ url = request.url.replace("http://", "https://", 1)
144
+ return redirect(url, code=301)
145
+
146
+ # Check if Turnstile verification is required
147
+ if app.config["TURNSTILE_ENABLED"]:
148
+ # Exclude verification routes
149
+ excluded_routes = ["verify_turnstile", "turnstile_page", "static"]
150
+ if request.endpoint not in excluded_routes:
151
+ # Check if user is verified
152
+ if not session.get("turnstile_verified"):
153
+ # Save original URL for redirect after verification
154
+ redirect_url = request.url
155
+ # Force HTTPS in HuggingFace Spaces
156
+ if IS_SPACES and redirect_url.startswith("http://"):
157
+ redirect_url = redirect_url.replace("http://", "https://", 1)
158
+
159
+ # If it's an API request, return a JSON response
160
+ if request.path.startswith("/api/"):
161
+ return jsonify({"error": "Turnstile verification required"}), 403
162
+ # For regular requests, redirect to verification page
163
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
164
+ else:
165
+ # Check if verification has expired (default: 24 hours)
166
+ verification_timeout = (
167
+ int(os.getenv("TURNSTILE_TIMEOUT_HOURS", "24")) * 3600
168
+ ) # Convert hours to seconds
169
+ verified_at = session.get("turnstile_verified_at", 0)
170
+ current_time = datetime.utcnow().timestamp()
171
+
172
+ if current_time - verified_at > verification_timeout:
173
+ # Verification expired, clear status and redirect to verification page
174
+ session.pop("turnstile_verified", None)
175
+ session.pop("turnstile_verified_at", None)
176
+
177
+ redirect_url = request.url
178
+ # Force HTTPS in HuggingFace Spaces
179
+ if IS_SPACES and redirect_url.startswith("http://"):
180
+ redirect_url = redirect_url.replace("http://", "https://", 1)
181
+
182
+ if request.path.startswith("/api/"):
183
+ return jsonify({"error": "Turnstile verification expired"}), 403
184
+ return redirect(
185
+ url_for("turnstile_page", redirect_url=redirect_url)
186
+ )
187
+
188
+
189
+ @app.route("/turnstile", methods=["GET"])
190
+ def turnstile_page():
191
+ """Display Cloudflare Turnstile verification page"""
192
+ redirect_url = request.args.get("redirect_url", url_for("arena", _external=True))
193
+
194
+ # Force HTTPS in HuggingFace Spaces
195
+ if IS_SPACES and redirect_url.startswith("http://"):
196
+ redirect_url = redirect_url.replace("http://", "https://", 1)
197
+
198
+ return render_template(
199
+ "turnstile.html",
200
+ turnstile_site_key=app.config["TURNSTILE_SITE_KEY"],
201
+ redirect_url=redirect_url,
202
+ )
203
+
204
+
205
+ @app.route("/verify-turnstile", methods=["POST"])
206
+ def verify_turnstile():
207
+ """Verify Cloudflare Turnstile token"""
208
+ token = request.form.get("cf-turnstile-response")
209
+ redirect_url = request.form.get("redirect_url", url_for("arena", _external=True))
210
+
211
+ # Force HTTPS in HuggingFace Spaces
212
+ if IS_SPACES and redirect_url.startswith("http://"):
213
+ redirect_url = redirect_url.replace("http://", "https://", 1)
214
+
215
+ if not token:
216
+ # If AJAX request, return JSON error
217
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
218
+ return (
219
+ jsonify({"success": False, "error": "Missing verification token"}),
220
+ 400,
221
+ )
222
+ # Otherwise redirect back to turnstile page
223
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
224
+
225
+ # Verify token with Cloudflare
226
+ data = {
227
+ "secret": app.config["TURNSTILE_SECRET_KEY"],
228
+ "response": token,
229
+ "remoteip": request.remote_addr,
230
+ }
231
+
232
+ try:
233
+ response = requests.post(app.config["TURNSTILE_VERIFY_URL"], data=data)
234
+ result = response.json()
235
+
236
+ if result.get("success"):
237
+ # Set verification status in session
238
+ session["turnstile_verified"] = True
239
+ session["turnstile_verified_at"] = datetime.utcnow().timestamp()
240
+
241
+ # Determine response type based on request
242
+ is_xhr = request.headers.get("X-Requested-With") == "XMLHttpRequest"
243
+ accepts_json = "application/json" in request.headers.get("Accept", "")
244
+
245
+ # If AJAX or JSON request, return success JSON
246
+ if is_xhr or accepts_json:
247
+ return jsonify({"success": True, "redirect": redirect_url})
248
+
249
+ # For regular form submissions, redirect to the target URL
250
+ return redirect(redirect_url)
251
+ else:
252
+ # Verification failed
253
+ app.logger.warning(f"Turnstile verification failed: {result}")
254
+
255
+ # If AJAX request, return JSON error
256
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
257
+ return jsonify({"success": False, "error": "Verification failed"}), 403
258
+
259
+ # Otherwise redirect back to turnstile page
260
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
261
+
262
+ except Exception as e:
263
+ app.logger.error(f"Turnstile verification error: {str(e)}")
264
+
265
+ # If AJAX request, return JSON error
266
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
267
+ return (
268
+ jsonify(
269
+ {"success": False, "error": "Server error during verification"}
270
+ ),
271
+ 500,
272
+ )
273
+
274
+ # Otherwise redirect back to turnstile page
275
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
276
+
277
+
278
+ @app.route("/")
279
+ def arena():
280
+ return render_template("arena.html")
281
+
282
+
283
+ @app.route("/leaderboard")
284
+ def leaderboard():
285
+ tts_leaderboard = get_leaderboard_data(ModelType.TTS)
286
+ conversational_leaderboard = get_leaderboard_data(ModelType.CONVERSATIONAL)
287
+ top_voters = get_top_voters(10) # Get top 10 voters
288
+
289
+ # Initialize personal leaderboard data
290
+ tts_personal_leaderboard = None
291
+ conversational_personal_leaderboard = None
292
+ user_leaderboard_visibility = None
293
+
294
+ # If user is logged in, get their personal leaderboard and visibility setting
295
+ if current_user.is_authenticated:
296
+ tts_personal_leaderboard = get_user_leaderboard(current_user.id, ModelType.TTS)
297
+ conversational_personal_leaderboard = get_user_leaderboard(
298
+ current_user.id, ModelType.CONVERSATIONAL
299
+ )
300
+ user_leaderboard_visibility = current_user.show_in_leaderboard
301
+
302
+ # Get key dates for the timeline
303
+ tts_key_dates = get_key_historical_dates(ModelType.TTS)
304
+ conversational_key_dates = get_key_historical_dates(ModelType.CONVERSATIONAL)
305
+
306
+ # Format dates for display in the dropdown
307
+ formatted_tts_dates = [date.strftime("%B %Y") for date in tts_key_dates]
308
+ formatted_conversational_dates = [
309
+ date.strftime("%B %Y") for date in conversational_key_dates
310
+ ]
311
+
312
+ return render_template(
313
+ "leaderboard.html",
314
+ tts_leaderboard=tts_leaderboard,
315
+ conversational_leaderboard=conversational_leaderboard,
316
+ tts_personal_leaderboard=tts_personal_leaderboard,
317
+ conversational_personal_leaderboard=conversational_personal_leaderboard,
318
+ tts_key_dates=tts_key_dates,
319
+ conversational_key_dates=conversational_key_dates,
320
+ formatted_tts_dates=formatted_tts_dates,
321
+ formatted_conversational_dates=formatted_conversational_dates,
322
+ top_voters=top_voters,
323
+ user_leaderboard_visibility=user_leaderboard_visibility
324
+ )
325
+
326
+
327
+ @app.route("/api/historical-leaderboard/<model_type>")
328
+ def historical_leaderboard(model_type):
329
+ """Get historical leaderboard data for a specific date"""
330
+ if model_type not in [ModelType.TTS, ModelType.CONVERSATIONAL]:
331
+ return jsonify({"error": "Invalid model type"}), 400
332
+
333
+ # Get date from query parameter
334
+ date_str = request.args.get("date")
335
+ if not date_str:
336
+ return jsonify({"error": "Date parameter is required"}), 400
337
+
338
+ try:
339
+ # Parse date from URL parameter (format: YYYY-MM-DD)
340
+ target_date = datetime.strptime(date_str, "%Y-%m-%d")
341
+
342
+ # Get historical leaderboard data
343
+ leaderboard_data = get_historical_leaderboard_data(model_type, target_date)
344
+
345
+ return jsonify(
346
+ {"date": target_date.strftime("%B %d, %Y"), "leaderboard": leaderboard_data}
347
+ )
348
+ except ValueError:
349
+ return jsonify({"error": "Invalid date format. Use YYYY-MM-DD"}), 400
350
+
351
+
352
+ @app.route("/about")
353
+ def about():
354
+ return render_template("about.html")
355
+
356
+
357
+ @app.route("/api/tts/generate", methods=["POST"])
358
+ @limiter.limit("10 per minute")
359
+ def generate_tts():
360
+ # If verification not setup, handle it first
361
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
362
+ return jsonify({"error": "Turnstile verification required"}), 403
363
+
364
+ data = request.json
365
+ text = data.get("text")
366
+
367
+ if not text or len(text) > 1000:
368
+ return jsonify({"error": "Invalid or too long text"}), 400
369
+
370
+ # Get two random TTS models
371
+ available_models = Model.query.filter_by(
372
+ model_type=ModelType.TTS, is_active=True
373
+ ).all()
374
+ if len(available_models) < 2:
375
+ return jsonify({"error": "Not enough TTS models available"}), 500
376
+
377
+ selected_models = random.sample(available_models, 2)
378
+
379
+ try:
380
+ # Generate TTS for both models concurrently
381
+ audio_files = []
382
+ model_ids = []
383
+
384
+ # Function to process a single model
385
+ def process_model(model):
386
+ # Call TTS service
387
+ audio_path = predict_tts(text, model.id)
388
+
389
+ # Copy to temp dir with unique name
390
+ file_uuid = str(uuid.uuid4())
391
+ dest_path = os.path.join(TEMP_AUDIO_DIR, f"{file_uuid}.wav")
392
+ shutil.copy(audio_path, dest_path)
393
+
394
+ return {"model_id": model.id, "audio_path": dest_path}
395
+
396
+ # Use ThreadPoolExecutor to process models concurrently
397
+ with ThreadPoolExecutor(max_workers=2) as executor:
398
+ results = list(executor.map(process_model, selected_models))
399
+
400
+ # Extract results
401
+ for result in results:
402
+ model_ids.append(result["model_id"])
403
+ audio_files.append(result["audio_path"])
404
+
405
+ # Create session
406
+ session_id = str(uuid.uuid4())
407
+ app.tts_sessions[session_id] = {
408
+ "model_a": model_ids[0],
409
+ "model_b": model_ids[1],
410
+ "audio_a": audio_files[0],
411
+ "audio_b": audio_files[1],
412
+ "text": text,
413
+ "created_at": datetime.utcnow(),
414
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
415
+ "voted": False,
416
+ }
417
+
418
+ # Return audio file paths and session
419
+ return jsonify(
420
+ {
421
+ "session_id": session_id,
422
+ "audio_a": f"/api/tts/audio/{session_id}/a",
423
+ "audio_b": f"/api/tts/audio/{session_id}/b",
424
+ "expires_in": 1800, # 30 minutes in seconds
425
+ }
426
+ )
427
+
428
+ except Exception as e:
429
+ app.logger.error(f"TTS generation error: {str(e)}")
430
+ return jsonify({"error": "Failed to generate TTS"}), 500
431
+
432
+
433
+ @app.route("/api/tts/audio/<session_id>/<model_key>")
434
+ def get_audio(session_id, model_key):
435
+ # If verification not setup, handle it first
436
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
437
+ return jsonify({"error": "Turnstile verification required"}), 403
438
+
439
+ if session_id not in app.tts_sessions:
440
+ return jsonify({"error": "Invalid or expired session"}), 404
441
+
442
+ session_data = app.tts_sessions[session_id]
443
+
444
+ # Check if session expired
445
+ if datetime.utcnow() > session_data["expires_at"]:
446
+ cleanup_session(session_id)
447
+ return jsonify({"error": "Session expired"}), 410
448
+
449
+ if model_key == "a":
450
+ audio_path = session_data["audio_a"]
451
+ elif model_key == "b":
452
+ audio_path = session_data["audio_b"]
453
+ else:
454
+ return jsonify({"error": "Invalid model key"}), 400
455
+
456
+ # Check if file exists
457
+ if not os.path.exists(audio_path):
458
+ return jsonify({"error": "Audio file not found"}), 404
459
+
460
+ return send_file(audio_path, mimetype="audio/wav")
461
+
462
+
463
+ @app.route("/api/tts/vote", methods=["POST"])
464
+ @limiter.limit("30 per minute")
465
+ def submit_vote():
466
+ # If verification not setup, handle it first
467
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
468
+ return jsonify({"error": "Turnstile verification required"}), 403
469
+
470
+ data = request.json
471
+ session_id = data.get("session_id")
472
+ chosen_model_key = data.get("chosen_model") # "a" or "b"
473
+
474
+ if not session_id or session_id not in app.tts_sessions:
475
+ return jsonify({"error": "Invalid or expired session"}), 404
476
+
477
+ if not chosen_model_key or chosen_model_key not in ["a", "b"]:
478
+ return jsonify({"error": "Invalid chosen model"}), 400
479
+
480
+ session_data = app.tts_sessions[session_id]
481
+
482
+ # Check if session expired
483
+ if datetime.utcnow() > session_data["expires_at"]:
484
+ cleanup_session(session_id)
485
+ return jsonify({"error": "Session expired"}), 410
486
+
487
+ # Check if already voted
488
+ if session_data["voted"]:
489
+ return jsonify({"error": "Vote already submitted for this session"}), 400
490
+
491
+ # Get model IDs and audio paths
492
+ chosen_id = (
493
+ session_data["model_a"] if chosen_model_key == "a" else session_data["model_b"]
494
+ )
495
+ rejected_id = (
496
+ session_data["model_b"] if chosen_model_key == "a" else session_data["model_a"]
497
+ )
498
+ chosen_audio_path = (
499
+ session_data["audio_a"] if chosen_model_key == "a" else session_data["audio_b"]
500
+ )
501
+ rejected_audio_path = (
502
+ session_data["audio_b"] if chosen_model_key == "a" else session_data["audio_a"]
503
+ )
504
+
505
+ # Record vote in database
506
+ user_id = current_user.id if current_user.is_authenticated else None
507
+ vote, error = record_vote(
508
+ user_id, session_data["text"], chosen_id, rejected_id, ModelType.TTS
509
+ )
510
+
511
+ if error:
512
+ return jsonify({"error": error}), 500
513
+
514
+ # --- Save preference data ---
515
+ try:
516
+ vote_uuid = str(uuid.uuid4())
517
+ vote_dir = os.path.join("./votes", vote_uuid)
518
+ os.makedirs(vote_dir, exist_ok=True)
519
+
520
+ # Copy audio files
521
+ shutil.copy(chosen_audio_path, os.path.join(vote_dir, "chosen.wav"))
522
+ shutil.copy(rejected_audio_path, os.path.join(vote_dir, "rejected.wav"))
523
+
524
+ # Create metadata
525
+ chosen_model_obj = Model.query.get(chosen_id)
526
+ rejected_model_obj = Model.query.get(rejected_id)
527
+ metadata = {
528
+ "text": session_data["text"],
529
+ "chosen_model": chosen_model_obj.name if chosen_model_obj else "Unknown",
530
+ "chosen_model_id": chosen_model_obj.id if chosen_model_obj else "Unknown",
531
+ "rejected_model": rejected_model_obj.name if rejected_model_obj else "Unknown",
532
+ "rejected_model_id": rejected_model_obj.id if rejected_model_obj else "Unknown",
533
+ "session_id": session_id,
534
+ "timestamp": datetime.utcnow().isoformat(),
535
+ "username": current_user.username if current_user.is_authenticated else None,
536
+ "model_type": "TTS"
537
+ }
538
+ with open(os.path.join(vote_dir, "metadata.json"), "w") as f:
539
+ json.dump(metadata, f, indent=2)
540
+
541
+ except Exception as e:
542
+ app.logger.error(f"Error saving preference data for vote {session_id}: {str(e)}")
543
+ # Continue even if saving preference data fails, vote is already recorded
544
+
545
+ # Mark session as voted
546
+ session_data["voted"] = True
547
+
548
+ # Return updated models (use previously fetched objects)
549
+ return jsonify(
550
+ {
551
+ "success": True,
552
+ "chosen_model": {"id": chosen_id, "name": chosen_model_obj.name if chosen_model_obj else "Unknown"},
553
+ "rejected_model": {
554
+ "id": rejected_id,
555
+ "name": rejected_model_obj.name if rejected_model_obj else "Unknown",
556
+ },
557
+ "names": {
558
+ "a": (
559
+ chosen_model_obj.name if chosen_model_key == "a" else rejected_model_obj.name
560
+ if chosen_model_obj and rejected_model_obj else "Unknown"
561
+ ),
562
+ "b": (
563
+ rejected_model_obj.name if chosen_model_key == "a" else chosen_model_obj.name
564
+ if chosen_model_obj and rejected_model_obj else "Unknown"
565
+ ),
566
+ },
567
+ }
568
+ )
569
+
570
+
571
+ def cleanup_session(session_id):
572
+ """Remove session and its audio files"""
573
+ if session_id in app.tts_sessions:
574
+ session = app.tts_sessions[session_id]
575
+
576
+ # Remove audio files
577
+ for audio_file in [session["audio_a"], session["audio_b"]]:
578
+ if os.path.exists(audio_file):
579
+ try:
580
+ os.remove(audio_file)
581
+ except Exception as e:
582
+ app.logger.error(f"Error removing audio file: {str(e)}")
583
+
584
+ # Remove session
585
+ del app.tts_sessions[session_id]
586
+
587
+
588
+ @app.route("/api/conversational/generate", methods=["POST"])
589
+ @limiter.limit("5 per minute")
590
+ def generate_podcast():
591
+ # If verification not setup, handle it first
592
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
593
+ return jsonify({"error": "Turnstile verification required"}), 403
594
+
595
+ data = request.json
596
+ script = data.get("script")
597
+
598
+ if not script or not isinstance(script, list) or len(script) < 2:
599
+ return jsonify({"error": "Invalid script format or too short"}), 400
600
+
601
+ # Validate script format
602
+ for line in script:
603
+ if not isinstance(line, dict) or "text" not in line or "speaker_id" not in line:
604
+ return (
605
+ jsonify(
606
+ {
607
+ "error": "Invalid script line format. Each line must have text and speaker_id"
608
+ }
609
+ ),
610
+ 400,
611
+ )
612
+ if (
613
+ not line["text"]
614
+ or not isinstance(line["speaker_id"], int)
615
+ or line["speaker_id"] not in [0, 1]
616
+ ):
617
+ return (
618
+ jsonify({"error": "Invalid script content. Speaker ID must be 0 or 1"}),
619
+ 400,
620
+ )
621
+
622
+ # Get two conversational models (currently only CSM and PlayDialog)
623
+ available_models = Model.query.filter_by(
624
+ model_type=ModelType.CONVERSATIONAL, is_active=True
625
+ ).all()
626
+
627
+ if len(available_models) < 2:
628
+ return jsonify({"error": "Not enough conversational models available"}), 500
629
+
630
+ selected_models = random.sample(available_models, 2)
631
+
632
+ try:
633
+ # Generate audio for both models concurrently
634
+ audio_files = []
635
+ model_ids = []
636
+
637
+ # Function to process a single model
638
+ def process_model(model):
639
+ # Call conversational TTS service
640
+ audio_content = predict_tts(script, model.id)
641
+
642
+ # Save to temp file with unique name
643
+ file_uuid = str(uuid.uuid4())
644
+ dest_path = os.path.join(TEMP_AUDIO_DIR, f"{file_uuid}.wav")
645
+
646
+ with open(dest_path, "wb") as f:
647
+ f.write(audio_content)
648
+
649
+ return {"model_id": model.id, "audio_path": dest_path}
650
+
651
+ # Use ThreadPoolExecutor to process models concurrently
652
+ with ThreadPoolExecutor(max_workers=2) as executor:
653
+ results = list(executor.map(process_model, selected_models))
654
+
655
+ # Extract results
656
+ for result in results:
657
+ model_ids.append(result["model_id"])
658
+ audio_files.append(result["audio_path"])
659
+
660
+ # Create session
661
+ session_id = str(uuid.uuid4())
662
+ script_text = " ".join([line["text"] for line in script])
663
+ app.conversational_sessions[session_id] = {
664
+ "model_a": model_ids[0],
665
+ "model_b": model_ids[1],
666
+ "audio_a": audio_files[0],
667
+ "audio_b": audio_files[1],
668
+ "text": script_text[:1000], # Limit text length
669
+ "created_at": datetime.utcnow(),
670
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
671
+ "voted": False,
672
+ "script": script,
673
+ }
674
+
675
+ # Return audio file paths and session
676
+ return jsonify(
677
+ {
678
+ "session_id": session_id,
679
+ "audio_a": f"/api/conversational/audio/{session_id}/a",
680
+ "audio_b": f"/api/conversational/audio/{session_id}/b",
681
+ "expires_in": 1800, # 30 minutes in seconds
682
+ }
683
+ )
684
+
685
+ except Exception as e:
686
+ app.logger.error(f"Conversational generation error: {str(e)}")
687
+ return jsonify({"error": f"Failed to generate podcast: {str(e)}"}), 500
688
+
689
+
690
+ @app.route("/api/conversational/audio/<session_id>/<model_key>")
691
+ def get_podcast_audio(session_id, model_key):
692
+ # If verification not setup, handle it first
693
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
694
+ return jsonify({"error": "Turnstile verification required"}), 403
695
+
696
+ if session_id not in app.conversational_sessions:
697
+ return jsonify({"error": "Invalid or expired session"}), 404
698
+
699
+ session_data = app.conversational_sessions[session_id]
700
+
701
+ # Check if session expired
702
+ if datetime.utcnow() > session_data["expires_at"]:
703
+ cleanup_conversational_session(session_id)
704
+ return jsonify({"error": "Session expired"}), 410
705
+
706
+ if model_key == "a":
707
+ audio_path = session_data["audio_a"]
708
+ elif model_key == "b":
709
+ audio_path = session_data["audio_b"]
710
+ else:
711
+ return jsonify({"error": "Invalid model key"}), 400
712
+
713
+ # Check if file exists
714
+ if not os.path.exists(audio_path):
715
+ return jsonify({"error": "Audio file not found"}), 404
716
+
717
+ return send_file(audio_path, mimetype="audio/wav")
718
+
719
+
720
+ @app.route("/api/conversational/vote", methods=["POST"])
721
+ @limiter.limit("30 per minute")
722
+ def submit_podcast_vote():
723
+ # If verification not setup, handle it first
724
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
725
+ return jsonify({"error": "Turnstile verification required"}), 403
726
+
727
+ data = request.json
728
+ session_id = data.get("session_id")
729
+ chosen_model_key = data.get("chosen_model") # "a" or "b"
730
+
731
+ if not session_id or session_id not in app.conversational_sessions:
732
+ return jsonify({"error": "Invalid or expired session"}), 404
733
+
734
+ if not chosen_model_key or chosen_model_key not in ["a", "b"]:
735
+ return jsonify({"error": "Invalid chosen model"}), 400
736
+
737
+ session_data = app.conversational_sessions[session_id]
738
+
739
+ # Check if session expired
740
+ if datetime.utcnow() > session_data["expires_at"]:
741
+ cleanup_conversational_session(session_id)
742
+ return jsonify({"error": "Session expired"}), 410
743
+
744
+ # Check if already voted
745
+ if session_data["voted"]:
746
+ return jsonify({"error": "Vote already submitted for this session"}), 400
747
+
748
+ # Get model IDs and audio paths
749
+ chosen_id = (
750
+ session_data["model_a"] if chosen_model_key == "a" else session_data["model_b"]
751
+ )
752
+ rejected_id = (
753
+ session_data["model_b"] if chosen_model_key == "a" else session_data["model_a"]
754
+ )
755
+ chosen_audio_path = (
756
+ session_data["audio_a"] if chosen_model_key == "a" else session_data["audio_b"]
757
+ )
758
+ rejected_audio_path = (
759
+ session_data["audio_b"] if chosen_model_key == "a" else session_data["audio_a"]
760
+ )
761
+
762
+ # Record vote in database
763
+ user_id = current_user.id if current_user.is_authenticated else None
764
+ vote, error = record_vote(
765
+ user_id, session_data["text"], chosen_id, rejected_id, ModelType.CONVERSATIONAL
766
+ )
767
+
768
+ if error:
769
+ return jsonify({"error": error}), 500
770
+
771
+ # --- Save preference data ---\
772
+ try:
773
+ vote_uuid = str(uuid.uuid4())
774
+ vote_dir = os.path.join("./votes", vote_uuid)
775
+ os.makedirs(vote_dir, exist_ok=True)
776
+
777
+ # Copy audio files
778
+ shutil.copy(chosen_audio_path, os.path.join(vote_dir, "chosen.wav"))
779
+ shutil.copy(rejected_audio_path, os.path.join(vote_dir, "rejected.wav"))
780
+
781
+ # Create metadata
782
+ chosen_model_obj = Model.query.get(chosen_id)
783
+ rejected_model_obj = Model.query.get(rejected_id)
784
+ metadata = {
785
+ "script": session_data["script"], # Save the full script
786
+ "chosen_model": chosen_model_obj.name if chosen_model_obj else "Unknown",
787
+ "chosen_model_id": chosen_model_obj.id if chosen_model_obj else "Unknown",
788
+ "rejected_model": rejected_model_obj.name if rejected_model_obj else "Unknown",
789
+ "rejected_model_id": rejected_model_obj.id if rejected_model_obj else "Unknown",
790
+ "session_id": session_id,
791
+ "timestamp": datetime.utcnow().isoformat(),
792
+ "username": current_user.username if current_user.is_authenticated else None,
793
+ "model_type": "CONVERSATIONAL"
794
+ }
795
+ with open(os.path.join(vote_dir, "metadata.json"), "w") as f:
796
+ json.dump(metadata, f, indent=2)
797
+
798
+ except Exception as e:
799
+ app.logger.error(f"Error saving preference data for conversational vote {session_id}: {str(e)}")
800
+ # Continue even if saving preference data fails, vote is already recorded
801
+
802
+ # Mark session as voted
803
+ session_data["voted"] = True
804
+
805
+ # Return updated models (use previously fetched objects)
806
+ return jsonify(
807
+ {
808
+ "success": True,
809
+ "chosen_model": {"id": chosen_id, "name": chosen_model_obj.name if chosen_model_obj else "Unknown"},
810
+ "rejected_model": {
811
+ "id": rejected_id,
812
+ "name": rejected_model_obj.name if rejected_model_obj else "Unknown",
813
+ },
814
+ "names": {
815
+ "a": Model.query.get(session_data["model_a"]).name,
816
+ "b": Model.query.get(session_data["model_b"]).name,
817
+ },
818
+ }
819
+ )
820
+
821
+
822
+ def cleanup_conversational_session(session_id):
823
+ """Remove conversational session and its audio files"""
824
+ if session_id in app.conversational_sessions:
825
+ session = app.conversational_sessions[session_id]
826
+
827
+ # Remove audio files
828
+ for audio_file in [session["audio_a"], session["audio_b"]]:
829
+ if os.path.exists(audio_file):
830
+ try:
831
+ os.remove(audio_file)
832
+ except Exception as e:
833
+ app.logger.error(
834
+ f"Error removing conversational audio file: {str(e)}"
835
+ )
836
+
837
+ # Remove session
838
+ del app.conversational_sessions[session_id]
839
+
840
+
841
+ # Schedule periodic cleanup
842
+ def setup_cleanup():
843
+ def cleanup_expired_sessions():
844
+ with app.app_context(): # Ensure app context for logging
845
+ current_time = datetime.utcnow()
846
+ # Cleanup TTS sessions
847
+ expired_tts_sessions = [
848
+ sid
849
+ for sid, session_data in app.tts_sessions.items()
850
+ if current_time > session_data["expires_at"]
851
+ ]
852
+ for sid in expired_tts_sessions:
853
+ cleanup_session(sid)
854
+
855
+ # Cleanup conversational sessions
856
+ expired_conv_sessions = [
857
+ sid
858
+ for sid, session_data in app.conversational_sessions.items()
859
+ if current_time > session_data["expires_at"]
860
+ ]
861
+ for sid in expired_conv_sessions:
862
+ cleanup_conversational_session(sid)
863
+ app.logger.info(f"Cleaned up {len(expired_tts_sessions)} TTS and {len(expired_conv_sessions)} conversational sessions.")
864
+
865
+
866
+ # Run cleanup every 15 minutes
867
+ scheduler = BackgroundScheduler()
868
+ scheduler.add_job(cleanup_expired_sessions, "interval", minutes=15)
869
+ scheduler.start()
870
+ print("Cleanup scheduler started") # Use print for startup messages
871
+
872
+
873
+ # Schedule periodic tasks (database sync and preference upload)
874
+ def setup_periodic_tasks():
875
+ """Setup periodic database synchronization and preference data upload for Spaces"""
876
+ if not IS_SPACES:
877
+ return
878
+
879
+ db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "instance/") # Get relative path
880
+ preferences_repo_id = "TTS-AGI/arena-v2-preferences"
881
+ database_repo_id = "TTS-AGI/database-arena-v2"
882
+ votes_dir = "./votes"
883
+
884
+ def sync_database():
885
+ """Uploads the database to HF dataset"""
886
+ with app.app_context(): # Ensure app context for logging
887
+ try:
888
+ if not os.path.exists(db_path):
889
+ app.logger.warning(f"Database file not found at {db_path}, skipping sync.")
890
+ return
891
+
892
+ api = HfApi(token=os.getenv("HF_TOKEN"))
893
+ api.upload_file(
894
+ path_or_fileobj=db_path,
895
+ path_in_repo="tts_arena.db",
896
+ repo_id=database_repo_id,
897
+ repo_type="dataset",
898
+ )
899
+ app.logger.info(f"Database uploaded to {database_repo_id} at {datetime.utcnow()}")
900
+ except Exception as e:
901
+ app.logger.error(f"Error uploading database to {database_repo_id}: {str(e)}")
902
+
903
+ def sync_preferences_data():
904
+ """Zips and uploads preference data folders to HF dataset"""
905
+ with app.app_context(): # Ensure app context for logging
906
+ if not os.path.isdir(votes_dir):
907
+ # app.logger.info(f"Votes directory '{votes_dir}' not found, skipping preference sync.")
908
+ return # Don't log every 5 mins if dir doesn't exist yet
909
+
910
+ try:
911
+ api = HfApi(token=os.getenv("HF_TOKEN"))
912
+ vote_uuids = [d for d in os.listdir(votes_dir) if os.path.isdir(os.path.join(votes_dir, d))]
913
+
914
+ if not vote_uuids:
915
+ # app.logger.info("No new preference data to upload.")
916
+ return # Don't log every 5 mins if no new data
917
+
918
+ uploaded_count = 0
919
+ for vote_uuid in vote_uuids:
920
+ dir_path = os.path.join(votes_dir, vote_uuid)
921
+ zip_base_path = os.path.join(votes_dir, vote_uuid) # Name zip file same as folder
922
+ zip_path = f"{zip_base_path}.zip"
923
+
924
+ try:
925
+ # Create zip archive
926
+ shutil.make_archive(zip_base_path, 'zip', dir_path)
927
+ app.logger.info(f"Created zip archive: {zip_path}")
928
+
929
+ # Upload zip file
930
+ api.upload_file(
931
+ path_or_fileobj=zip_path,
932
+ path_in_repo=f"votes/{year}/{month}/{vote_uuid}.zip",
933
+ repo_id=preferences_repo_id,
934
+ repo_type="dataset",
935
+ commit_message=f"Add preference data {vote_uuid}"
936
+ )
937
+ app.logger.info(f"Successfully uploaded {zip_path} to {preferences_repo_id}")
938
+ uploaded_count += 1
939
+
940
+ # Cleanup local files after successful upload
941
+ try:
942
+ os.remove(zip_path)
943
+ shutil.rmtree(dir_path)
944
+ app.logger.info(f"Cleaned up local files: {zip_path} and {dir_path}")
945
+ except OSError as e:
946
+ app.logger.error(f"Error cleaning up files for {vote_uuid}: {str(e)}")
947
+
948
+ except Exception as upload_err:
949
+ app.logger.error(f"Error processing or uploading preference data for {vote_uuid}: {str(upload_err)}")
950
+ # Optionally remove zip if it exists but upload failed
951
+ if os.path.exists(zip_path):
952
+ try:
953
+ os.remove(zip_path)
954
+ except OSError as e:
955
+ app.logger.error(f"Error removing zip file after failed upload {zip_path}: {str(e)}")
956
+ # Keep the original folder for the next attempt
957
+
958
+ if uploaded_count > 0:
959
+ app.logger.info(f"Finished preference data sync. Uploaded {uploaded_count} new entries.")
960
+
961
+ except Exception as e:
962
+ app.logger.error(f"General error during preference data sync: {str(e)}")
963
+
964
+
965
+ # Schedule periodic tasks
966
+ scheduler = BackgroundScheduler()
967
+ # Sync database less frequently if needed, e.g., every 15 minutes
968
+ scheduler.add_job(sync_database, "interval", minutes=15, id="sync_db_job")
969
+ # Sync preferences more frequently
970
+ scheduler.add_job(sync_preferences_data, "interval", minutes=5, id="sync_pref_job")
971
+ scheduler.start()
972
+ print("Periodic tasks scheduler started (DB sync and Preferences upload)") # Use print for startup
973
+
974
+
975
+ @app.cli.command("init-db")
976
+ def init_db():
977
+ """Initialize the database."""
978
+ with app.app_context():
979
+ db.create_all()
980
+ print("Database initialized!")
981
+
982
+
983
+ @app.route("/api/toggle-leaderboard-visibility", methods=["POST"])
984
+ def toggle_leaderboard_visibility():
985
+ """Toggle whether the current user appears in the top voters leaderboard"""
986
+ if not current_user.is_authenticated:
987
+ return jsonify({"error": "You must be logged in to change this setting"}), 401
988
+
989
+ new_status = toggle_user_leaderboard_visibility(current_user.id)
990
+ if new_status is None:
991
+ return jsonify({"error": "User not found"}), 404
992
+
993
+ return jsonify({
994
+ "success": True,
995
+ "visible": new_status,
996
+ "message": "You are now visible in the voters leaderboard" if new_status else "You are now hidden from the voters leaderboard"
997
+ })
998
+
999
 
1000
  if __name__ == "__main__":
1001
+ with app.app_context():
1002
+ # Ensure ./instance and ./votes directories exist
1003
+ os.makedirs("instance", exist_ok=True)
1004
+ os.makedirs("./votes", exist_ok=True) # Create votes directory if it doesn't exist
1005
+
1006
+ # Download database if it doesn't exist (only on initial space start)
1007
+ if IS_SPACES and not os.path.exists(app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")):
1008
+ try:
1009
+ print("Database not found, downloading from HF dataset...")
1010
+ hf_hub_download(
1011
+ repo_id="TTS-AGI/database-arena-v2",
1012
+ filename="tts_arena.db",
1013
+ repo_type="dataset",
1014
+ local_dir="instance", # download to instance/
1015
+ token=os.getenv("HF_TOKEN"),
1016
+ )
1017
+ print("Database downloaded successfully ✅")
1018
+ except Exception as e:
1019
+ print(f"Error downloading database from HF dataset: {str(e)} ⚠️")
1020
+
1021
+
1022
+ db.create_all() # Create tables if they don't exist
1023
+ insert_initial_models()
1024
+ # Setup background tasks
1025
+ setup_cleanup()
1026
+ setup_periodic_tasks() # Renamed function call
1027
+
1028
+ # Configure Flask to recognize HTTPS when behind a reverse proxy
1029
+ from werkzeug.middleware.proxy_fix import ProxyFix
1030
+
1031
+ # Apply ProxyFix middleware to handle reverse proxy headers
1032
+ # This ensures Flask generates correct URLs with https scheme
1033
+ # X-Forwarded-Proto header will be used to detect the original protocol
1034
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
1035
+
1036
+ # Force Flask to prefer HTTPS for generated URLs
1037
+ app.config["PREFERRED_URL_SCHEME"] = "https"
1038
+
1039
+ from waitress import serve
1040
+
1041
+ # Configuration for 2 vCPUs:
1042
+ # - threads: typically 4-8 threads per CPU core is a good balance
1043
+ # - connection_limit: maximum concurrent connections
1044
+ # - channel_timeout: prevent hanging connections
1045
+ threads = 12 # 6 threads per vCPU is a good balance for mixed IO/CPU workloads
1046
+
1047
+ if IS_SPACES:
1048
+ serve(
1049
+ app,
1050
+ host="0.0.0.0",
1051
+ port=int(os.environ.get("PORT", 7860)),
1052
+ threads=threads,
1053
+ connection_limit=100,
1054
+ channel_timeout=30,
1055
+ url_scheme='https'
1056
+ )
1057
+ else:
1058
+ print(f"Starting Waitress server with {threads} threads")
1059
+ serve(
1060
+ app,
1061
+ host="0.0.0.0",
1062
+ port=5000,
1063
+ threads=threads,
1064
+ connection_limit=100,
1065
+ channel_timeout=30,
1066
+ url_scheme='https' # Keep https for local dev if using proxy/tunnel
1067
+ )
auth.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, redirect, url_for, session, request, current_app, flash
2
+ from flask_login import login_user, logout_user, current_user, login_required
3
+ from authlib.integrations.flask_client import OAuth
4
+ import os
5
+ from models import db, User
6
+ import requests
7
+ from functools import wraps
8
+
9
+ auth = Blueprint("auth", __name__)
10
+ oauth = OAuth()
11
+
12
+
13
+ def init_oauth(app):
14
+ oauth.init_app(app)
15
+ oauth.register(
16
+ name="huggingface",
17
+ client_id=os.getenv("OAUTH_CLIENT_ID"),
18
+ client_secret=os.getenv("OAUTH_CLIENT_SECRET"),
19
+ access_token_url="https://huggingface.co/oauth/token",
20
+ access_token_params=None,
21
+ authorize_url="https://huggingface.co/oauth/authorize",
22
+ authorize_params=None,
23
+ api_base_url="https://huggingface.co/api/",
24
+ client_kwargs={},
25
+ )
26
+
27
+
28
+ def is_admin(user):
29
+ """Check if a user is in the ADMIN_USERS environment variable"""
30
+ if not user or not user.is_authenticated:
31
+ return False
32
+
33
+ admin_users = os.getenv("ADMIN_USERS", "").split(",")
34
+ return user.username in [username.strip() for username in admin_users]
35
+
36
+
37
+ def admin_required(f):
38
+ """Decorator to require admin access for a route"""
39
+ @wraps(f)
40
+ def decorated_function(*args, **kwargs):
41
+ if not current_user.is_authenticated:
42
+ flash("Please log in to access this page", "error")
43
+ return redirect(url_for("auth.login", next=request.url))
44
+
45
+ if not is_admin(current_user):
46
+ flash("You do not have permission to access this page", "error")
47
+ return redirect(url_for("arena"))
48
+
49
+ return f(*args, **kwargs)
50
+ return decorated_function
51
+
52
+
53
+ @auth.route("/login")
54
+ def login():
55
+ # Store the next URL to redirect after login
56
+ next_url = request.args.get("next") or url_for("arena")
57
+ session["next_url"] = next_url
58
+
59
+ redirect_uri = url_for("auth.authorize", _external=True, _scheme="https")
60
+ return oauth.huggingface.authorize_redirect(redirect_uri)
61
+
62
+
63
+ @auth.route("/authorize")
64
+ def authorize():
65
+ try:
66
+ # Get token without OpenID verification
67
+ token = oauth.huggingface.authorize_access_token()
68
+
69
+ # Fetch user info manually from HF API
70
+ headers = {"Authorization": f'Bearer {token["access_token"]}'}
71
+ resp = requests.get("https://huggingface.co/api/whoami-v2", headers=headers)
72
+
73
+ if not resp.ok:
74
+ flash("Failed to fetch user information from Hugging Face", "error")
75
+ return redirect(url_for("arena"))
76
+
77
+ user_info = resp.json()
78
+
79
+ # Check if user exists, otherwise create
80
+ user = User.query.filter_by(hf_id=user_info["id"]).first()
81
+ if not user:
82
+ user = User(username=user_info["name"], hf_id=user_info["id"])
83
+ db.session.add(user)
84
+ db.session.commit()
85
+
86
+ # Log in the user
87
+ login_user(user, remember=True)
88
+
89
+ # Redirect to the original page or default
90
+ next_url = session.pop("next_url", url_for("arena"))
91
+ return redirect(next_url)
92
+
93
+ except Exception as e:
94
+ current_app.logger.error(f"OAuth error: {str(e)}")
95
+ flash(f"Authentication error: {str(e)}", "error")
96
+ return redirect(url_for("arena"))
97
+
98
+
99
+ @auth.route("/logout")
100
+ @login_required
101
+ def logout():
102
+ logout_user()
103
+ flash("You have been logged out", "info")
104
+ return redirect(url_for("arena"))
models.py ADDED
@@ -0,0 +1,575 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask_sqlalchemy import SQLAlchemy
2
+ from flask_login import UserMixin
3
+ from datetime import datetime
4
+ import math
5
+ from sqlalchemy import func
6
+
7
+ db = SQLAlchemy()
8
+
9
+
10
+ class User(db.Model, UserMixin):
11
+ id = db.Column(db.Integer, primary_key=True)
12
+ username = db.Column(db.String(100), unique=True, nullable=False)
13
+ hf_id = db.Column(db.String(100), unique=True, nullable=False)
14
+ join_date = db.Column(db.DateTime, default=datetime.utcnow)
15
+ votes = db.relationship("Vote", backref="user", lazy=True)
16
+ show_in_leaderboard = db.Column(db.Boolean, default=True)
17
+
18
+ def __repr__(self):
19
+ return f"<User {self.username}>"
20
+
21
+
22
+ class ModelType:
23
+ TTS = "tts"
24
+ CONVERSATIONAL = "conversational"
25
+
26
+
27
+ class Model(db.Model):
28
+ id = db.Column(db.String(100), primary_key=True)
29
+ name = db.Column(db.String(100), nullable=False)
30
+ model_type = db.Column(db.String(20), nullable=False) # 'tts' or 'conversational'
31
+ # Fix ambiguous foreign keys by specifying which foreign key to use
32
+ votes = db.relationship(
33
+ "Vote",
34
+ primaryjoin="or_(Model.id==Vote.model_chosen, Model.id==Vote.model_rejected)",
35
+ viewonly=True,
36
+ )
37
+ current_elo = db.Column(db.Float, default=1500.0)
38
+ win_count = db.Column(db.Integer, default=0)
39
+ match_count = db.Column(db.Integer, default=0)
40
+ is_open = db.Column(db.Boolean, default=False)
41
+ is_active = db.Column(
42
+ db.Boolean, default=True
43
+ ) # Whether the model is active and can be voted on
44
+ model_url = db.Column(db.String(255), nullable=True)
45
+
46
+ @property
47
+ def win_rate(self):
48
+ if self.match_count == 0:
49
+ return 0
50
+ return (self.win_count / self.match_count) * 100
51
+
52
+ def __repr__(self):
53
+ return f"<Model {self.name} ({self.model_type})>"
54
+
55
+
56
+ class Vote(db.Model):
57
+ id = db.Column(db.Integer, primary_key=True)
58
+ user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
59
+ text = db.Column(db.String(1000), nullable=False)
60
+ vote_date = db.Column(db.DateTime, default=datetime.utcnow)
61
+ model_chosen = db.Column(db.String(100), db.ForeignKey("model.id"), nullable=False)
62
+ model_rejected = db.Column(
63
+ db.String(100), db.ForeignKey("model.id"), nullable=False
64
+ )
65
+ model_type = db.Column(db.String(20), nullable=False) # 'tts' or 'conversational'
66
+
67
+ chosen = db.relationship(
68
+ "Model",
69
+ foreign_keys=[model_chosen],
70
+ backref=db.backref("chosen_votes", lazy=True),
71
+ )
72
+ rejected = db.relationship(
73
+ "Model",
74
+ foreign_keys=[model_rejected],
75
+ backref=db.backref("rejected_votes", lazy=True),
76
+ )
77
+
78
+ def __repr__(self):
79
+ return f"<Vote {self.id}: {self.model_chosen} over {self.model_rejected} ({self.model_type})>"
80
+
81
+
82
+ class EloHistory(db.Model):
83
+ id = db.Column(db.Integer, primary_key=True)
84
+ model_id = db.Column(db.String(100), db.ForeignKey("model.id"), nullable=False)
85
+ timestamp = db.Column(db.DateTime, default=datetime.utcnow)
86
+ elo_score = db.Column(db.Float, nullable=False)
87
+ vote_id = db.Column(db.Integer, db.ForeignKey("vote.id"), nullable=True)
88
+ model_type = db.Column(db.String(20), nullable=False) # 'tts' or 'conversational'
89
+
90
+ model = db.relationship("Model", backref=db.backref("elo_history", lazy=True))
91
+ vote = db.relationship("Vote", backref=db.backref("elo_changes", lazy=True))
92
+
93
+ def __repr__(self):
94
+ return f"<EloHistory {self.model_id}: {self.elo_score} at {self.timestamp} ({self.model_type})>"
95
+
96
+
97
+ def calculate_elo_change(winner_elo, loser_elo, k_factor=32):
98
+ """Calculate Elo rating changes for a match."""
99
+ expected_winner = 1 / (1 + math.pow(10, (loser_elo - winner_elo) / 400))
100
+ expected_loser = 1 / (1 + math.pow(10, (winner_elo - loser_elo) / 400))
101
+
102
+ winner_new_elo = winner_elo + k_factor * (1 - expected_winner)
103
+ loser_new_elo = loser_elo + k_factor * (0 - expected_loser)
104
+
105
+ return winner_new_elo, loser_new_elo
106
+
107
+
108
+ def record_vote(user_id, text, chosen_model_id, rejected_model_id, model_type):
109
+ """Record a vote and update Elo ratings."""
110
+ # Create the vote
111
+ vote = Vote(
112
+ user_id=user_id, # Can be None for anonymous votes
113
+ text=text,
114
+ model_chosen=chosen_model_id,
115
+ model_rejected=rejected_model_id,
116
+ model_type=model_type,
117
+ )
118
+ db.session.add(vote)
119
+ db.session.flush() # Get the vote ID without committing
120
+
121
+ # Get the models
122
+ chosen_model = Model.query.filter_by(
123
+ id=chosen_model_id, model_type=model_type
124
+ ).first()
125
+ rejected_model = Model.query.filter_by(
126
+ id=rejected_model_id, model_type=model_type
127
+ ).first()
128
+
129
+ if not chosen_model or not rejected_model:
130
+ db.session.rollback()
131
+ return None, "One or both models not found for the specified model type"
132
+
133
+ # Calculate new Elo ratings
134
+ new_chosen_elo, new_rejected_elo = calculate_elo_change(
135
+ chosen_model.current_elo, rejected_model.current_elo
136
+ )
137
+
138
+ # Update model stats
139
+ chosen_model.current_elo = new_chosen_elo
140
+ chosen_model.win_count += 1
141
+ chosen_model.match_count += 1
142
+
143
+ rejected_model.current_elo = new_rejected_elo
144
+ rejected_model.match_count += 1
145
+
146
+ # Record Elo history
147
+ chosen_history = EloHistory(
148
+ model_id=chosen_model_id,
149
+ elo_score=new_chosen_elo,
150
+ vote_id=vote.id,
151
+ model_type=model_type,
152
+ )
153
+
154
+ rejected_history = EloHistory(
155
+ model_id=rejected_model_id,
156
+ elo_score=new_rejected_elo,
157
+ vote_id=vote.id,
158
+ model_type=model_type,
159
+ )
160
+
161
+ db.session.add_all([chosen_history, rejected_history])
162
+ db.session.commit()
163
+
164
+ return vote, None
165
+
166
+
167
+ def get_leaderboard_data(model_type):
168
+ """
169
+ Get leaderboard data for the specified model type.
170
+
171
+ Args:
172
+ model_type (str): The model type ('tts' or 'conversational')
173
+
174
+ Returns:
175
+ list: List of dictionaries containing model data for the leaderboard
176
+ """
177
+ query = Model.query.filter_by(model_type=model_type)
178
+
179
+ # Get models ordered by ELO score
180
+ models = query.order_by(Model.current_elo.desc()).all()
181
+
182
+ result = []
183
+ for rank, model in enumerate(models, 1):
184
+ # Determine tier based on rank
185
+ if rank <= 2:
186
+ tier = "tier-s"
187
+ elif rank <= 4:
188
+ tier = "tier-a"
189
+ elif rank <= 7:
190
+ tier = "tier-b"
191
+ else:
192
+ tier = ""
193
+
194
+ result.append(
195
+ {
196
+ "rank": rank,
197
+ "id": model.id,
198
+ "name": model.name,
199
+ "model_url": model.model_url,
200
+ "win_rate": f"{model.win_rate:.0f}%",
201
+ "total_votes": model.match_count,
202
+ "elo": int(model.current_elo),
203
+ "tier": tier,
204
+ "is_open": model.is_open,
205
+ }
206
+ )
207
+
208
+ return result
209
+
210
+
211
+ def get_user_leaderboard(user_id, model_type):
212
+ """
213
+ Get personalized leaderboard data for a specific user.
214
+
215
+ Args:
216
+ user_id (int): The user ID
217
+ model_type (str): The model type ('tts' or 'conversational')
218
+
219
+ Returns:
220
+ list: List of dictionaries containing model data for the user's personal leaderboard
221
+ """
222
+ # Get all models of the specified type
223
+ models = Model.query.filter_by(model_type=model_type).all()
224
+
225
+ # Get user's votes
226
+ user_votes = Vote.query.filter_by(user_id=user_id, model_type=model_type).all()
227
+
228
+ # Calculate win counts and match counts for each model based on user's votes
229
+ model_stats = {model.id: {"wins": 0, "matches": 0} for model in models}
230
+
231
+ for vote in user_votes:
232
+ model_stats[vote.model_chosen]["wins"] += 1
233
+ model_stats[vote.model_chosen]["matches"] += 1
234
+ model_stats[vote.model_rejected]["matches"] += 1
235
+
236
+ # Calculate win rates and prepare result
237
+ result = []
238
+ for model in models:
239
+ stats = model_stats[model.id]
240
+ win_rate = (
241
+ (stats["wins"] / stats["matches"] * 100) if stats["matches"] > 0 else 0
242
+ )
243
+
244
+ # Only include models the user has voted on
245
+ if stats["matches"] > 0:
246
+ result.append(
247
+ {
248
+ "id": model.id,
249
+ "name": model.name,
250
+ "model_url": model.model_url,
251
+ "win_rate": f"{win_rate:.0f}%",
252
+ "total_votes": stats["matches"],
253
+ "wins": stats["wins"],
254
+ "is_open": model.is_open,
255
+ }
256
+ )
257
+
258
+ # Sort by win rate descending
259
+ result.sort(key=lambda x: float(x["win_rate"].rstrip("%")), reverse=True)
260
+
261
+ # Add rank
262
+ for i, item in enumerate(result, 1):
263
+ item["rank"] = i
264
+
265
+ return result
266
+
267
+
268
+ def get_historical_leaderboard_data(model_type, target_date=None):
269
+ """
270
+ Get leaderboard data at a specific date in history.
271
+
272
+ Args:
273
+ model_type (str): The model type ('tts' or 'conversational')
274
+ target_date (datetime): The target date for historical data, defaults to current time
275
+
276
+ Returns:
277
+ list: List of dictionaries containing model data for the historical leaderboard
278
+ """
279
+ if not target_date:
280
+ target_date = datetime.utcnow()
281
+
282
+ # Get all models of the specified type
283
+ models = Model.query.filter_by(model_type=model_type).all()
284
+
285
+ # Create a result list for the models
286
+ result = []
287
+
288
+ for model in models:
289
+ # Get the most recent EloHistory entry for each model before the target date
290
+ elo_entry = (
291
+ EloHistory.query.filter(
292
+ EloHistory.model_id == model.id,
293
+ EloHistory.model_type == model_type,
294
+ EloHistory.timestamp <= target_date,
295
+ )
296
+ .order_by(EloHistory.timestamp.desc())
297
+ .first()
298
+ )
299
+
300
+ # Skip models that have no history before the target date
301
+ if not elo_entry:
302
+ continue
303
+
304
+ # Count wins and matches up to the target date
305
+ match_count = Vote.query.filter(
306
+ db.or_(Vote.model_chosen == model.id, Vote.model_rejected == model.id),
307
+ Vote.model_type == model_type,
308
+ Vote.vote_date <= target_date,
309
+ ).count()
310
+
311
+ win_count = Vote.query.filter(
312
+ Vote.model_chosen == model.id,
313
+ Vote.model_type == model_type,
314
+ Vote.vote_date <= target_date,
315
+ ).count()
316
+
317
+ # Calculate win rate
318
+ win_rate = (win_count / match_count * 100) if match_count > 0 else 0
319
+
320
+ # Add to result
321
+ result.append(
322
+ {
323
+ "id": model.id,
324
+ "name": model.name,
325
+ "model_url": model.model_url,
326
+ "win_rate": f"{win_rate:.0f}%",
327
+ "total_votes": match_count,
328
+ "elo": int(elo_entry.elo_score),
329
+ "is_open": model.is_open,
330
+ }
331
+ )
332
+
333
+ # Sort by ELO score descending
334
+ result.sort(key=lambda x: x["elo"], reverse=True)
335
+
336
+ # Add rank and tier
337
+ for i, item in enumerate(result, 1):
338
+ item["rank"] = i
339
+ # Determine tier based on rank
340
+ if i <= 2:
341
+ item["tier"] = "tier-s"
342
+ elif i <= 4:
343
+ item["tier"] = "tier-a"
344
+ elif i <= 7:
345
+ item["tier"] = "tier-b"
346
+ else:
347
+ item["tier"] = ""
348
+
349
+ return result
350
+
351
+
352
+ def get_key_historical_dates(model_type):
353
+ """
354
+ Get a list of key dates in the leaderboard history.
355
+
356
+ Args:
357
+ model_type (str): The model type ('tts' or 'conversational')
358
+
359
+ Returns:
360
+ list: List of datetime objects representing key dates
361
+ """
362
+ # Get first and most recent vote dates
363
+ first_vote = (
364
+ Vote.query.filter_by(model_type=model_type)
365
+ .order_by(Vote.vote_date.asc())
366
+ .first()
367
+ )
368
+ last_vote = (
369
+ Vote.query.filter_by(model_type=model_type)
370
+ .order_by(Vote.vote_date.desc())
371
+ .first()
372
+ )
373
+
374
+ if not first_vote or not last_vote:
375
+ return []
376
+
377
+ # Generate a list of key dates - first day of each month between the first and last vote
378
+ dates = []
379
+ current_date = first_vote.vote_date.replace(day=1)
380
+ end_date = last_vote.vote_date
381
+
382
+ while current_date <= end_date:
383
+ dates.append(current_date)
384
+ # Move to next month
385
+ if current_date.month == 12:
386
+ current_date = current_date.replace(year=current_date.year + 1, month=1)
387
+ else:
388
+ current_date = current_date.replace(month=current_date.month + 1)
389
+
390
+ # Add latest date
391
+ if dates and dates[-1].month != end_date.month or dates[-1].year != end_date.year:
392
+ dates.append(end_date)
393
+
394
+ return dates
395
+
396
+
397
+ def insert_initial_models():
398
+ """Insert initial models into the database."""
399
+ tts_models = [
400
+ Model(
401
+ id="eleven-multilingual-v2",
402
+ name="Eleven Multilingual v2",
403
+ model_type=ModelType.TTS,
404
+ is_open=False,
405
+ model_url="https://elevenlabs.io/",
406
+ ),
407
+ Model(
408
+ id="eleven-turbo-v2.5",
409
+ name="Eleven Turbo v2.5",
410
+ model_type=ModelType.TTS,
411
+ is_open=False,
412
+ model_url="https://elevenlabs.io/",
413
+ ),
414
+ Model(
415
+ id="eleven-flash-v2.5",
416
+ name="Eleven Flash v2.5",
417
+ model_type=ModelType.TTS,
418
+ is_open=False,
419
+ model_url="https://elevenlabs.io/",
420
+ ),
421
+ Model(
422
+ id="cartesia-sonic-2",
423
+ name="Cartesia Sonic 2",
424
+ model_type=ModelType.TTS,
425
+ is_open=False,
426
+ model_url="https://cartesia.ai/",
427
+ ),
428
+ Model(
429
+ id="spark-tts",
430
+ name="Spark TTS",
431
+ model_type=ModelType.TTS,
432
+ is_open=False,
433
+ model_url="https://github.com/SparkAudio/Spark-TTS",
434
+ ),
435
+ Model(
436
+ id="playht-2.0",
437
+ name="PlayHT 2.0",
438
+ model_type=ModelType.TTS,
439
+ is_open=False,
440
+ model_url="https://play.ht/",
441
+ ),
442
+ Model(
443
+ id="styletts2",
444
+ name="StyleTTS 2",
445
+ model_type=ModelType.TTS,
446
+ is_open=True,
447
+ model_url="https://github.com/yl4579/StyleTTS2",
448
+ ),
449
+ Model(
450
+ id="kokoro-v1",
451
+ name="Kokoro v1.0",
452
+ model_type=ModelType.TTS,
453
+ is_open=True,
454
+ model_url="https://huggingface.co/hexgrad/Kokoro-82M",
455
+ ),
456
+ Model(
457
+ id="cosyvoice-2.0",
458
+ name="CosyVoice 2.0",
459
+ model_type=ModelType.TTS,
460
+ is_open=True,
461
+ model_url="https://github.com/FunAudioLLM/CosyVoice",
462
+ ),
463
+ Model(
464
+ id="papla-p1",
465
+ name="Papla P1",
466
+ model_type=ModelType.TTS,
467
+ is_open=False,
468
+ model_url="https://papla.media/",
469
+ ),
470
+ Model(
471
+ id="hume-octave",
472
+ name="Hume Octave",
473
+ model_type=ModelType.TTS,
474
+ is_open=False,
475
+ model_url="https://hume.ai/",
476
+ ),
477
+ Model(
478
+ id="megatts3",
479
+ name="MegaTTS 3",
480
+ model_type=ModelType.TTS,
481
+ is_open=True,
482
+ model_url="https://github.com/bytedance/MegaTTS3",
483
+ ),
484
+ ]
485
+ conversational_models = [
486
+ Model(
487
+ id="csm-1b",
488
+ name="CSM 1B",
489
+ model_type=ModelType.CONVERSATIONAL,
490
+ is_open=True,
491
+ model_url="https://huggingface.co/sesame/csm-1b",
492
+ ),
493
+ Model(
494
+ id="playdialog-1.0",
495
+ name="PlayDialog 1.0",
496
+ model_type=ModelType.CONVERSATIONAL,
497
+ is_open=False,
498
+ model_url="https://play.ht/",
499
+ ),
500
+ Model(
501
+ id="dia-1.6b",
502
+ name="Dia 1.6B",
503
+ model_type=ModelType.CONVERSATIONAL,
504
+ is_open=True,
505
+ model_url="https://huggingface.co/nari-labs/Dia-1.6B",
506
+ ),
507
+ ]
508
+
509
+ all_models = tts_models + conversational_models
510
+
511
+ for model in all_models:
512
+ existing = Model.query.filter_by(
513
+ id=model.id, model_type=model.model_type
514
+ ).first()
515
+ if not existing:
516
+ db.session.add(model)
517
+ else:
518
+ # Update model attributes if they've changed, but preserve other data
519
+ existing.name = model.name
520
+ existing.is_open = model.is_open
521
+ if model.is_active is not None:
522
+ existing.is_active = model.is_active
523
+
524
+ db.session.commit()
525
+
526
+
527
+ def get_top_voters(limit=10):
528
+ """
529
+ Get the top voters by number of votes.
530
+
531
+ Args:
532
+ limit (int): Number of users to return
533
+
534
+ Returns:
535
+ list: List of dictionaries containing user data and vote counts
536
+ """
537
+ # Query users who have opted in to the leaderboard and have at least one vote
538
+ top_users = db.session.query(
539
+ User, func.count(Vote.id).label('vote_count')
540
+ ).join(Vote).filter(
541
+ User.show_in_leaderboard == True
542
+ ).group_by(User.id).order_by(
543
+ func.count(Vote.id).desc()
544
+ ).limit(limit).all()
545
+
546
+ result = []
547
+ for i, (user, vote_count) in enumerate(top_users, 1):
548
+ result.append({
549
+ "rank": i,
550
+ "username": user.username,
551
+ "vote_count": vote_count,
552
+ "join_date": user.join_date.strftime("%b %d, %Y")
553
+ })
554
+
555
+ return result
556
+
557
+
558
+ def toggle_user_leaderboard_visibility(user_id):
559
+ """
560
+ Toggle whether a user appears in the voters leaderboard
561
+
562
+ Args:
563
+ user_id (int): The user ID
564
+
565
+ Returns:
566
+ bool: New visibility state
567
+ """
568
+ user = User.query.get(user_id)
569
+ if not user:
570
+ return None
571
+
572
+ user.show_in_leaderboard = not user.show_in_leaderboard
573
+ db.session.commit()
574
+
575
+ return user.show_in_leaderboard
requirements.txt CHANGED
@@ -1,8 +1,14 @@
1
- datasets
2
- librosa
3
- soundfile
4
- gradio-client
5
- git+https://github.com/unitaryai/detoxify
6
- pyloudnorm
7
- langdetect
8
- pydub
 
 
 
 
 
 
 
1
+ flask
2
+ flask-login
3
+ flask-sqlalchemy
4
+ python-dotenv
5
+ requests
6
+ authlib
7
+ werkzeug
8
+ flask-limiter
9
+ apscheduler
10
+ flask-migrate
11
+ gunicorn
12
+ waitress
13
+ fal-client
14
+ git+https://github.com/playht/pyht
static/closed.svg ADDED
static/css/waveplayer.css ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* WavePlayer Component Styles */
2
+ .waveplayer {
3
+ position: relative;
4
+ width: 100%;
5
+ background-color: var(--light-gray, #f5f5f5);
6
+ border-radius: 8px;
7
+ padding: 16px;
8
+ margin-bottom: 16px;
9
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
10
+ overflow: hidden;
11
+ }
12
+
13
+ /* Hide native audio elements */
14
+ .waveplayer audio {
15
+ display: none !important;
16
+ }
17
+
18
+ .waveplayer-controls {
19
+ display: flex;
20
+ align-items: center;
21
+ margin-bottom: 12px;
22
+ gap: 12px;
23
+ }
24
+
25
+ .waveplayer-play-btn {
26
+ width: 40px;
27
+ height: 40px;
28
+ border-radius: 50%;
29
+ background-color: var(--primary-color, #5046e5);
30
+ color: white;
31
+ border: none;
32
+ display: flex;
33
+ align-items: center;
34
+ justify-content: center;
35
+ cursor: pointer;
36
+ transition: background-color 0.2s, transform 0.1s;
37
+ flex-shrink: 0;
38
+ }
39
+
40
+ .waveplayer-play-btn:hover {
41
+ background-color: var(--primary-hover, #4038c7);
42
+ }
43
+
44
+ .waveplayer-play-btn:active {
45
+ transform: scale(0.95);
46
+ }
47
+
48
+ .waveplayer-play-btn svg {
49
+ width: 20px;
50
+ height: 20px;
51
+ }
52
+
53
+ .waveplayer-time {
54
+ font-size: 14px;
55
+ color: var(--text-color, #333);
56
+ font-weight: 500;
57
+ flex-shrink: 0;
58
+ }
59
+
60
+ .waveplayer-waveform {
61
+ position: relative;
62
+ width: 100%;
63
+ height: 80px;
64
+ cursor: pointer;
65
+ }
66
+
67
+ .waveplayer-loading {
68
+ position: absolute;
69
+ top: 0;
70
+ left: 0;
71
+ width: 100%;
72
+ height: 100%;
73
+ background-color: rgba(255, 255, 255, 0.7);
74
+ display: flex;
75
+ flex-direction: column;
76
+ align-items: center;
77
+ justify-content: center;
78
+ -webkit-backdrop-filter: blur(2px);
79
+ backdrop-filter: blur(2px);
80
+ z-index: 10;
81
+ }
82
+
83
+ .waveplayer-spinner {
84
+ width: 24px;
85
+ height: 24px;
86
+ border: 3px solid rgba(80, 70, 229, 0.3);
87
+ border-radius: 50%;
88
+ border-top-color: var(--primary-color, #5046e5);
89
+ animation: spin 1s linear infinite;
90
+ margin-bottom: 8px;
91
+ }
92
+
93
+ @keyframes spin {
94
+ to {
95
+ transform: rotate(360deg);
96
+ }
97
+ }
98
+
99
+ /* Dark mode styles */
100
+ @media (prefers-color-scheme: dark) {
101
+ .waveplayer {
102
+ background-color: var(--light-gray, #1e1e24);
103
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
104
+ }
105
+
106
+ .waveplayer-time {
107
+ color: var(--text-color, #e0e0e0);
108
+ }
109
+
110
+ .waveplayer-loading {
111
+ background-color: rgba(30, 30, 36, 0.7);
112
+ }
113
+ }
114
+
115
+ /* Mobile optimizations */
116
+ @media (max-width: 768px) {
117
+ .waveplayer {
118
+ padding: 12px;
119
+ }
120
+
121
+ .waveplayer-play-btn {
122
+ width: 44px;
123
+ height: 44px;
124
+ }
125
+
126
+ .waveplayer-waveform {
127
+ height: 70px;
128
+ touch-action: none; /* Prevents scroll/zoom on touch */
129
+ }
130
+
131
+ .waveplayer-time {
132
+ font-size: 12px;
133
+ }
134
+ }
static/huggingface.svg ADDED
static/js/waveplayer.js ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class WavePlayer {
2
+ constructor(container, options = {}) {
3
+ this.container = container;
4
+ this.options = {
5
+ waveColor: '#d1d6e0',
6
+ progressColor: '#5046e5',
7
+ cursorColor: '#5046e5',
8
+ cursorWidth: 2,
9
+ height: 80,
10
+ responsive: true,
11
+ barWidth: 2,
12
+ barGap: 1,
13
+ hideScrollbar: true,
14
+ ...options
15
+ };
16
+
17
+ this.isPlaying = false;
18
+ this.wavesurfer = null;
19
+ this.loadingIndicator = null;
20
+ this.playButton = null;
21
+
22
+ this.init();
23
+ }
24
+
25
+ init() {
26
+ // Create player UI
27
+ this.buildUI();
28
+
29
+ // Initialize wavesurfer
30
+ this.initWavesurfer();
31
+
32
+ // Setup event listeners
33
+ this.setupEvents();
34
+ }
35
+
36
+ buildUI() {
37
+ // Clear container
38
+ this.container.innerHTML = '';
39
+ this.container.classList.add('waveplayer');
40
+
41
+ // Add style to hide native audio elements that might be rendered by wavesurfer
42
+ const style = document.createElement('style');
43
+ style.textContent = `
44
+ .waveplayer audio {
45
+ display: none !important;
46
+ }
47
+
48
+ /* Mobile optimizations */
49
+ @media (max-width: 768px) {
50
+ .waveplayer-play-btn {
51
+ width: 44px;
52
+ height: 44px;
53
+ margin-right: 12px;
54
+ }
55
+
56
+ .waveplayer-waveform {
57
+ height: 70px;
58
+ cursor: pointer;
59
+ touch-action: none; /* Prevents scroll/zoom on touch */
60
+ }
61
+ }
62
+ `;
63
+ this.container.appendChild(style);
64
+
65
+ // Create elements
66
+ const waveformContainer = document.createElement('div');
67
+ waveformContainer.className = 'waveplayer-waveform';
68
+
69
+ const controlsContainer = document.createElement('div');
70
+ controlsContainer.className = 'waveplayer-controls';
71
+
72
+ // Play button
73
+ this.playButton = document.createElement('button');
74
+ this.playButton.className = 'waveplayer-play-btn';
75
+ this.playButton.innerHTML = `
76
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="play-icon">
77
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
78
+ </svg>
79
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pause-icon" style="display: none;">
80
+ <rect x="6" y="4" width="4" height="16"></rect>
81
+ <rect x="14" y="4" width="4" height="16"></rect>
82
+ </svg>
83
+ `;
84
+
85
+ // Time display
86
+ this.timeDisplay = document.createElement('div');
87
+ this.timeDisplay.className = 'waveplayer-time';
88
+ this.timeDisplay.textContent = '0:00 / 0:00';
89
+
90
+ // Loading indicator
91
+ this.loadingIndicator = document.createElement('div');
92
+ this.loadingIndicator.className = 'waveplayer-loading';
93
+ this.loadingIndicator.innerHTML = `
94
+ <div class="waveplayer-spinner"></div>
95
+ <span>Loading...</span>
96
+ `;
97
+
98
+ // Set up MutationObserver to detect when loading reaches 100%
99
+ const loadingTextElement = this.loadingIndicator.querySelector('span');
100
+ if (loadingTextElement) {
101
+ const observer = new MutationObserver((mutations) => {
102
+ mutations.forEach((mutation) => {
103
+ if (mutation.type === 'characterData' || mutation.type === 'childList') {
104
+ const text = loadingTextElement.textContent;
105
+ if (text && text.includes('100%')) {
106
+ // If we see "100%", hide the loading indicator after a short delay
107
+ setTimeout(() => this.hideLoading(), 300);
108
+ }
109
+ }
110
+ });
111
+ });
112
+
113
+ observer.observe(loadingTextElement, {
114
+ characterData: true,
115
+ childList: true,
116
+ subtree: true
117
+ });
118
+ }
119
+
120
+ // Append elements
121
+ controlsContainer.appendChild(this.playButton);
122
+ controlsContainer.appendChild(this.timeDisplay);
123
+
124
+ this.container.appendChild(controlsContainer);
125
+ this.container.appendChild(waveformContainer);
126
+ this.container.appendChild(this.loadingIndicator);
127
+
128
+ // Store reference to waveform container
129
+ this.waveformContainer = waveformContainer;
130
+ }
131
+
132
+ initWavesurfer() {
133
+ // Initialize WaveSurfer
134
+ this.wavesurfer = WaveSurfer.create({
135
+ container: this.waveformContainer,
136
+ ...this.options,
137
+ // Add mobile touch support
138
+ interact: true,
139
+ dragToSeek: true
140
+ });
141
+
142
+ // Force reset any loading indicators
143
+ if (this.loadingIndicator) {
144
+ this.loadingIndicator.style.display = 'none';
145
+ }
146
+ }
147
+
148
+ setupEvents() {
149
+ // Play/pause button
150
+ this.playButton.addEventListener('click', () => {
151
+ this.togglePlayPause();
152
+ });
153
+
154
+ // Add touch support for mobile
155
+ this.playButton.addEventListener('touchstart', (e) => {
156
+ e.preventDefault();
157
+ this.togglePlayPause();
158
+ });
159
+
160
+ // Add touch support for waveform container
161
+ this.waveformContainer.addEventListener('touchstart', (e) => {
162
+ // This helps ensure the touch events propagate correctly to wavesurfer
163
+ e.stopPropagation();
164
+ });
165
+
166
+ // Wavesurfer events
167
+ this.wavesurfer.on('ready', () => {
168
+ // Clear loading timeout
169
+ if (this.loadingTimeout) {
170
+ clearTimeout(this.loadingTimeout);
171
+ }
172
+
173
+ // Explicitly ensure loading indicator is hidden
174
+ this.hideLoading();
175
+ this.updateTimeDisplay();
176
+
177
+ // Force loading message to be reset
178
+ if (this.loadingIndicator && this.loadingIndicator.querySelector('span')) {
179
+ this.loadingIndicator.querySelector('span').textContent = 'Loading...';
180
+ }
181
+
182
+ console.log('WavePlayer ready event fired');
183
+ });
184
+
185
+ // Add specific handler for decode event (fired when audio is decoded)
186
+ this.wavesurfer.on('decode', () => {
187
+ // Also hide loading indicator after decode
188
+ this.hideLoading();
189
+ console.log('WavePlayer decode event fired');
190
+ });
191
+
192
+ // Add specific handler for loading complete
193
+ this.wavesurfer.on('loading', (percent) => {
194
+ this.showLoading(percent);
195
+
196
+ // If loading reaches 100%, make sure to hide the loader after a small delay
197
+ if (percent === 100) {
198
+ setTimeout(() => {
199
+ this.hideLoading();
200
+ console.log('WavePlayer loading 100% - force hiding loader');
201
+ }, 500);
202
+ }
203
+ });
204
+
205
+ this.wavesurfer.on('play', () => {
206
+ this.isPlaying = true;
207
+ this.updatePlayButton();
208
+ });
209
+
210
+ this.wavesurfer.on('pause', () => {
211
+ this.isPlaying = false;
212
+ this.updatePlayButton();
213
+ });
214
+
215
+ this.wavesurfer.on('finish', () => {
216
+ this.isPlaying = false;
217
+ this.updatePlayButton();
218
+ });
219
+
220
+ this.wavesurfer.on('audioprocess', () => {
221
+ this.updateTimeDisplay();
222
+ });
223
+
224
+ this.wavesurfer.on('seek', () => {
225
+ this.updateTimeDisplay();
226
+ });
227
+
228
+ this.wavesurfer.on('error', (err) => {
229
+ console.error('WaveSurfer error:', err);
230
+ this.hideLoading();
231
+ });
232
+ }
233
+
234
+ loadAudio(url) {
235
+ this.showLoading();
236
+ this.wavesurfer.load(url);
237
+
238
+ // Safety timeout to ensure loading indicator gets hidden
239
+ // even if the 'ready' event doesn't fire properly
240
+ this.loadingTimeout = setTimeout(() => {
241
+ this.hideLoading();
242
+ }, 10000); // 10 seconds max loading time
243
+ }
244
+
245
+ play() {
246
+ this.wavesurfer.play();
247
+ }
248
+
249
+ pause() {
250
+ this.wavesurfer.pause();
251
+ }
252
+
253
+ togglePlayPause() {
254
+ this.wavesurfer.playPause();
255
+ }
256
+
257
+ stop() {
258
+ this.wavesurfer.stop();
259
+ }
260
+
261
+ updatePlayButton() {
262
+ const playIcon = this.playButton.querySelector('.play-icon');
263
+ const pauseIcon = this.playButton.querySelector('.pause-icon');
264
+
265
+ if (this.isPlaying) {
266
+ playIcon.style.display = 'none';
267
+ pauseIcon.style.display = 'block';
268
+ } else {
269
+ playIcon.style.display = 'block';
270
+ pauseIcon.style.display = 'none';
271
+ }
272
+ }
273
+
274
+ showLoading(percent) {
275
+ this.loadingIndicator.style.display = 'flex';
276
+ if (percent !== undefined) {
277
+ this.loadingIndicator.querySelector('span').textContent = `Loading: ${Math.round(percent)}%`;
278
+ }
279
+ }
280
+
281
+ hideLoading() {
282
+ if (this.loadingIndicator) {
283
+ this.loadingIndicator.style.display = 'none';
284
+
285
+ // Reset loading text
286
+ const loadingText = this.loadingIndicator.querySelector('span');
287
+ if (loadingText) {
288
+ loadingText.textContent = 'Loading...';
289
+ }
290
+ }
291
+ }
292
+
293
+ formatTime(seconds) {
294
+ const minutes = Math.floor(seconds / 60);
295
+ const secondsRemainder = Math.round(seconds) % 60;
296
+ const paddedSeconds = secondsRemainder.toString().padStart(2, '0');
297
+ return `${minutes}:${paddedSeconds}`;
298
+ }
299
+
300
+ updateTimeDisplay() {
301
+ if (!this.wavesurfer.isReady) return;
302
+
303
+ const currentTime = this.formatTime(this.wavesurfer.getCurrentTime());
304
+ const duration = this.formatTime(this.wavesurfer.getDuration());
305
+ this.timeDisplay.textContent = `${currentTime} / ${duration}`;
306
+ }
307
+ }
308
+
309
+ // Allow global access
310
+ window.WavePlayer = WavePlayer;
static/open.svg ADDED
static/twitter.svg ADDED
templates/about.html ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}About - TTS Arena{% endblock %}
4
+
5
+ {% block current_page %}About{% endblock %}
6
+
7
+ {% block extra_head %}
8
+ <style>
9
+ .about-container {
10
+ max-width: 800px;
11
+ margin: 0 auto;
12
+ }
13
+
14
+ .about-section {
15
+ background: white;
16
+ border-radius: var(--radius);
17
+ padding: 24px;
18
+ margin-bottom: 24px;
19
+ box-shadow: var(--shadow);
20
+ }
21
+
22
+ .about-section h2 {
23
+ color: var(--primary-color);
24
+ margin-bottom: 16px;
25
+ font-size: 24px;
26
+ }
27
+
28
+ .about-section p {
29
+ margin-bottom: 16px;
30
+ line-height: 1.6;
31
+ color: #444;
32
+ }
33
+
34
+ .about-section p:last-child {
35
+ margin-bottom: 0;
36
+ }
37
+
38
+ .feature-list {
39
+ list-style: none;
40
+ padding: 0;
41
+ }
42
+
43
+ .feature-list li {
44
+ margin-bottom: 12px;
45
+ padding-left: 28px;
46
+ position: relative;
47
+ }
48
+
49
+ .feature-list li::before {
50
+ content: "•";
51
+ color: var(--primary-color);
52
+ font-size: 24px;
53
+ position: absolute;
54
+ left: 8px;
55
+ top: -4px;
56
+ }
57
+
58
+ .credits-list {
59
+ display: grid;
60
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
61
+ gap: 24px;
62
+ margin-top: 16px;
63
+ }
64
+
65
+ .credit-item {
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: space-between;
69
+ padding-bottom: 8px;
70
+ border-bottom: 1px solid var(--border-color);
71
+ }
72
+
73
+ .credit-item a {
74
+ color: var(--primary-color);
75
+ text-decoration: none;
76
+ }
77
+
78
+ .credit-item a:hover {
79
+ text-decoration: underline;
80
+ }
81
+
82
+ .social-links {
83
+ display: flex;
84
+ gap: 12px;
85
+ }
86
+
87
+ .social-icon {
88
+ width: 20px;
89
+ height: 20px;
90
+ }
91
+
92
+ .citation-box {
93
+ background-color: var(--light-gray);
94
+ border-radius: var(--radius);
95
+ padding: 16px;
96
+ margin-top: 16px;
97
+ position: relative;
98
+ font-family: monospace;
99
+ white-space: pre-wrap;
100
+ word-break: break-word;
101
+ font-size: 14px;
102
+ line-height: 1.5;
103
+ }
104
+
105
+ .copy-citation {
106
+ position: absolute;
107
+ top: 8px;
108
+ right: 8px;
109
+ background-color: white;
110
+ border: 1px solid var(--border-color);
111
+ border-radius: var(--radius);
112
+ width: 36px;
113
+ height: 36px;
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: center;
117
+ cursor: pointer;
118
+ transition: background-color 0.2s;
119
+ }
120
+
121
+ .copy-citation:hover {
122
+ background-color: var(--light-gray);
123
+ }
124
+
125
+ .copy-citation svg {
126
+ color: var(--text-color);
127
+ }
128
+
129
+ .faq-item {
130
+ margin-bottom: 20px;
131
+ }
132
+
133
+ .faq-question {
134
+ font-weight: 600;
135
+ margin-bottom: 8px;
136
+ color: var(--primary-color);
137
+ }
138
+
139
+ .faq-answer {
140
+ line-height: 1.6;
141
+ }
142
+ /* Dark mode styles */
143
+ @media (prefers-color-scheme: dark) {
144
+ .about-section {
145
+ background-color: var(--light-gray);
146
+ border-color: var(--border-color);
147
+ }
148
+
149
+ .about-section p {
150
+ color: var(--text-color);
151
+ }
152
+
153
+ .citation-box {
154
+ background-color: var(--secondary-color);
155
+ border-color: var(--border-color);
156
+ }
157
+
158
+ .copy-citation {
159
+ background-color: var(--light-gray);
160
+ border-color: var(--border-color);
161
+ }
162
+
163
+ .copy-citation:hover {
164
+ background-color: rgba(255, 255, 255, 0.1);
165
+ }
166
+
167
+ .copy-citation svg {
168
+ color: var(--text-color);
169
+ }
170
+
171
+ .faq-question {
172
+ color: var(--primary-color);
173
+ }
174
+
175
+ .social-icon.icon-x {
176
+ filter: invert(1);
177
+ }
178
+ }
179
+
180
+ </style>
181
+ {% endblock %}
182
+
183
+ {% block content %}
184
+ <div class="about-container">
185
+ <div class="about-section">
186
+ <h2>Welcome to TTS Arena 2.0</h2>
187
+ <p>
188
+ TTS Arena evaluates leading speech synthesis models in an interactive, community-driven platform.
189
+ Inspired by LMsys's <a href="https://chat.lmsys.org/" target="_blank" rel="noopener">Chatbot Arena</a>, we've created
190
+ a space where anyone can compare and rank text-to-speech technologies through direct, side-by-side evaluation.
191
+ </p>
192
+ <p>
193
+ Our second version now supports conversational models for podcast-like content generation, expanding the arena's scope to reflect the diverse applications of modern speech synthesis.
194
+ </p>
195
+ </div>
196
+
197
+ <div class="about-section">
198
+ <h2>Motivation</h2>
199
+ <p>
200
+ The field of speech synthesis has long lacked reliable methods to measure model quality. Traditional
201
+ metrics like WER (word error rate) often fail to capture the nuances of natural speech, while subjective
202
+ measures such as MOS (mean opinion score) typically involve small-scale experiments with limited participants.
203
+ </p>
204
+ <p>
205
+ TTS Arena addresses these limitations by inviting the entire community to participate in the evaluation
206
+ process, making both the opportunity to rank models and the resulting insights accessible to everyone.
207
+ </p>
208
+ </div>
209
+
210
+ <div class="about-section">
211
+ <h2>How The Arena Works</h2>
212
+ <p>
213
+ The concept is straightforward: enter text that will be synthesized by two competing models. After
214
+ listening to both samples, vote for the one that sounds more natural and engaging. To prevent bias,
215
+ model names are revealed only after your vote is submitted.
216
+ </p>
217
+ <ul class="feature-list">
218
+ <li>Enter your own text or select a random sentence</li>
219
+ <li>Listen to two different TTS models synthesize the same content</li>
220
+ <li>Compare conversational models for podcast-like content</li>
221
+ <li>Vote for the model that sounds more natural, clear, and expressive</li>
222
+ <li>Track model rankings on our leaderboard</li>
223
+ </ul>
224
+ </div>
225
+
226
+ <div class="about-section">
227
+ <h2>Frequently Asked Questions</h2>
228
+ <div class="faq-item">
229
+ <div class="faq-question">What happened to the TTS Arena V1 leaderboard?</div>
230
+ <div class="faq-answer">
231
+ The TTS Arena V1 leaderboard is now deprecated. While you can no longer vote on it, the results and leaderboard are still available for reference at <a href="https://huggingface.co/spaces/TTS-AGI/TTS-Arena" target="_blank" rel="noopener">TTS Arena V1</a>. The leaderboard is static and will not change.
232
+ </div>
233
+ </div>
234
+ <div class="faq-item">
235
+ <div class="faq-question">How are models ranked in TTS Arena?</div>
236
+ <div class="faq-answer">
237
+ Models are ranked using an Elo rating system, similar to chess rankings. When you vote for a model, its rating increases while the other model's rating decreases. The amount of change depends on the current ratings of both models.
238
+ </div>
239
+ </div>
240
+ <div class="faq-item">
241
+ <div class="faq-question">Is the TTS Arena V2 leaderboard affected by votes from V1?</div>
242
+ <div class="faq-answer">
243
+ No, the TTS Arena V2 leaderboard is a completely fresh start. Votes from V1 do not affect the V2 leaderboard in any way. All models in V2 start with a clean slate.
244
+ </div>
245
+ </div>
246
+ <div class="faq-item">
247
+ <div class="faq-question">Can I suggest a model to be added to the arena?</div>
248
+ <div class="faq-answer">
249
+ Yes! We welcome suggestions for new models. Please reach out to us through the Hugging Face community or create an issue in our GitHub repository. If you are developing a new model and wish for it to be added anonymously for pre-release evaluation, please <a href="mailto:[email protected]" target="_blank" rel="noopener">reach out to us to discuss</a>.
250
+ </div>
251
+ </div>
252
+ <div class="faq-item">
253
+ <div class="faq-question">How can I contribute to the project?</div>
254
+ <div class="faq-answer">
255
+ You can contribute by voting on models, suggesting improvements, reporting bugs, or even contributing code. Check our GitHub repository for more information on how to get involved.
256
+ </div>
257
+ </div>
258
+ <div class="faq-item">
259
+ <div class="faq-question">What's new in TTS Arena 2.0?</div>
260
+ <div class="faq-answer">
261
+ TTS Arena 2.0 introduces support for conversational models (for podcast-like content), improved UI/UX, and a more robust backend infrastructure for handling more models and votes.
262
+ </div>
263
+ </div>
264
+ <div class="faq-item">
265
+ <div class="faq-question">Do I need to login to use TTS Arena?</div>
266
+ <div class="faq-answer">
267
+ Login is optional and not required to vote. If you choose to login (with Hugging Face), texts you enter will be associated with your account, and you'll have access to a personal leaderboard showing the models you favor the most.
268
+ </div>
269
+ </div>
270
+ </div>
271
+
272
+ <div class="about-section">
273
+ <h2>Citation</h2>
274
+ <p>
275
+ If you use TTS Arena in your research, please cite it as follows:
276
+ </p>
277
+ <div class="citation-box" id="citation-text">@misc{tts-arena-v2,
278
+ title = {TTS Arena 2.0: Benchmarking Text-to-Speech Models in the Wild},
279
+ author = {mrfakename and Srivastav, Vaibhav and Fourrier, Clémentine and Pouget, Lucain and Lacombe, Yoach and main and Gandhi, Sanchit and Passos, Apolinário and Cuenca, Pedro},
280
+ year = 2025,
281
+ publisher = {Hugging Face},
282
+ howpublished = "\url{https://huggingface.co/spaces/TTS-AGI/TTS-Arena-V2}"
283
+ }<button class="copy-citation" onclick="copyToClipboard()" title="Copy citation"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></button></div>
284
+ <script>
285
+ function copyToClipboard() {
286
+ const text = document.getElementById('citation-text').innerText;
287
+ navigator.clipboard.writeText(text).then(() => {
288
+ const btn = document.querySelector('.copy-citation');
289
+ const originalContent = btn.innerHTML;
290
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
291
+ setTimeout(() => {
292
+ btn.innerHTML = originalContent;
293
+ }, 2000);
294
+ });
295
+ }
296
+ </script>
297
+ </div>
298
+
299
+ <div class="about-section">
300
+ <h2>Credits</h2>
301
+ <p>
302
+ Thank you to the following individuals who helped make this project possible:
303
+ </p>
304
+ <div class="credits-list">
305
+ <div class="credit-item">
306
+ <span>Vaibhav (VB) Srivastav</span>
307
+ <div class="social-links">
308
+ <a href="https://twitter.com/reach_vb" target="_blank" rel="noopener" title="Twitter">
309
+ <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
310
+ </a>
311
+ <a href="https://huggingface.co/reach-vb" target="_blank" rel="noopener" title="Hugging Face">
312
+ <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
313
+ </a>
314
+ </div>
315
+ </div>
316
+ <div class="credit-item">
317
+ <span>Clémentine Fourrier</span>
318
+ <div class="social-links">
319
+ <a href="https://twitter.com/clefourrier" target="_blank" rel="noopener" title="Twitter">
320
+ <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
321
+ </a>
322
+ <a href="https://huggingface.co/clefourrier" target="_blank" rel="noopener" title="Hugging Face">
323
+ <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
324
+ </a>
325
+ </div>
326
+ </div>
327
+ <div class="credit-item">
328
+ <span>Lucain Pouget</span>
329
+ <div class="social-links">
330
+ <a href="https://twitter.com/Wauplin" target="_blank" rel="noopener" title="Twitter">
331
+ <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
332
+ </a>
333
+ <a href="https://huggingface.co/Wauplin" target="_blank" rel="noopener" title="Hugging Face">
334
+ <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
335
+ </a>
336
+ </div>
337
+ </div>
338
+ <div class="credit-item">
339
+ <span>Yoach Lacombe</span>
340
+ <div class="social-links">
341
+ <a href="https://twitter.com/yoachlacombe" target="_blank" rel="noopener" title="Twitter">
342
+ <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
343
+ </a>
344
+ <a href="https://huggingface.co/ylacombe" target="_blank" rel="noopener" title="Hugging Face">
345
+ <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
346
+ </a>
347
+ </div>
348
+ </div>
349
+ <div class="credit-item">
350
+ <span>Main Horse</span>
351
+ <div class="social-links">
352
+ <a href="https://twitter.com/main_horse" target="_blank" rel="noopener" title="Twitter">
353
+ <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
354
+ </a>
355
+ <a href="https://huggingface.co/main-horse" target="_blank" rel="noopener" title="Hugging Face">
356
+ <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
357
+ </a>
358
+ </div>
359
+ </div>
360
+ <div class="credit-item">
361
+ <span>Sanchit Gandhi</span>
362
+ <div class="social-links">
363
+ <a href="https://twitter.com/sanchitgandhi99" target="_blank" rel="noopener" title="Twitter">
364
+ <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
365
+ </a>
366
+ <a href="https://huggingface.co/sanchit-gandhi" target="_blank" rel="noopener" title="Hugging Face">
367
+ <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
368
+ </a>
369
+ </div>
370
+ </div>
371
+ <div class="credit-item">
372
+ <span>Apolinário Passos</span>
373
+ <div class="social-links">
374
+ <a href="https://twitter.com/multimodalart" target="_blank" rel="noopener" title="Twitter">
375
+ <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
376
+ </a>
377
+ <a href="https://huggingface.co/multimodalart" target="_blank" rel="noopener" title="Hugging Face">
378
+ <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
379
+ </a>
380
+ </div>
381
+ </div>
382
+ <div class="credit-item">
383
+ <span>Pedro Cuenca</span>
384
+ <div class="social-links">
385
+ <a href="https://twitter.com/pcuenq" target="_blank" rel="noopener" title="Twitter">
386
+ <img src="{{ url_for('static', filename='twitter.svg') }}" alt="Twitter" class="social-icon icon-x">
387
+ </a>
388
+ <a href="https://huggingface.co/pcuenq" target="_blank" rel="noopener" title="Hugging Face">
389
+ <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face" class="social-icon">
390
+ </a>
391
+ </div>
392
+ </div>
393
+ </div>
394
+ </div>
395
+
396
+ <div class="about-section">
397
+ <h2>Privacy Statement</h2>
398
+ <p>
399
+ We may store text you enter and generated audio. If you are logged in, we may associate your votes with your Hugging Face username.
400
+ You agree that we may collect, share, and/or publish any data you input for research and/or
401
+ commercial purposes.
402
+ </p>
403
+ </div>
404
+
405
+ <div class="about-section">
406
+ <h2>License</h2>
407
+ <p>
408
+ Generated audio clips cannot be redistributed and may be used for personal, non-commercial use only.
409
+ The code for the Arena is licensed under the Zlib license.
410
+ Random sentences are sourced from a filtered subset of the
411
+ <a href="https://www.cs.columbia.edu/~hgs/audio/harvard.html" target="_blank" rel="noopener">Harvard Sentences</a>.
412
+ </p>
413
+ </div>
414
+ </div>
415
+ {% endblock %}
templates/admin/activity.html ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">Activity Monitoring</div>
6
+ </div>
7
+
8
+ <div class="admin-stats">
9
+ <div class="stat-card">
10
+ <div class="stat-title">Active TTS Sessions</div>
11
+ <div class="stat-value">{{ tts_session_count }}</div>
12
+ </div>
13
+ <div class="stat-card">
14
+ <div class="stat-title">Active Conversational Sessions</div>
15
+ <div class="stat-value">{{ conversational_session_count }}</div>
16
+ </div>
17
+ </div>
18
+
19
+ <div class="admin-card">
20
+ <div class="admin-card-header">
21
+ <div class="admin-card-title">Activity Past 24 Hours</div>
22
+ </div>
23
+ <canvas id="hourlyActivityChart" height="250"></canvas>
24
+ </div>
25
+
26
+ <div class="admin-card">
27
+ <div class="admin-card-header">
28
+ <div class="admin-card-title">Recent TTS Votes</div>
29
+ </div>
30
+ <div class="table-responsive">
31
+ <table class="admin-table">
32
+ <thead>
33
+ <tr>
34
+ <th>Time</th>
35
+ <th>User</th>
36
+ <th>Chosen Model</th>
37
+ <th>Rejected Model</th>
38
+ <th>Text</th>
39
+ </tr>
40
+ </thead>
41
+ <tbody>
42
+ {% for vote in recent_tts_votes %}
43
+ <tr>
44
+ <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
45
+ <td>
46
+ {% if vote.user %}
47
+ <a href="{{ url_for('admin.user_detail', user_id=vote.user.id) }}">{{ vote.user.username }}</a>
48
+ {% else %}
49
+ Anonymous
50
+ {% endif %}
51
+ </td>
52
+ <td>{{ vote.chosen.name }}</td>
53
+ <td>{{ vote.rejected.name }}</td>
54
+ <td>
55
+ <div class="text-truncate" title="{{ vote.text }}">
56
+ {{ vote.text }}
57
+ </div>
58
+ </td>
59
+ </tr>
60
+ {% endfor %}
61
+ </tbody>
62
+ </table>
63
+ </div>
64
+ </div>
65
+
66
+ <div class="admin-card">
67
+ <div class="admin-card-header">
68
+ <div class="admin-card-title">Recent Conversational Votes</div>
69
+ </div>
70
+ <div class="table-responsive">
71
+ <table class="admin-table">
72
+ <thead>
73
+ <tr>
74
+ <th>Time</th>
75
+ <th>User</th>
76
+ <th>Chosen Model</th>
77
+ <th>Rejected Model</th>
78
+ <th>Text Preview</th>
79
+ </tr>
80
+ </thead>
81
+ <tbody>
82
+ {% for vote in recent_conv_votes %}
83
+ <tr>
84
+ <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
85
+ <td>
86
+ {% if vote.user %}
87
+ <a href="{{ url_for('admin.user_detail', user_id=vote.user.id) }}">{{ vote.user.username }}</a>
88
+ {% else %}
89
+ Anonymous
90
+ {% endif %}
91
+ </td>
92
+ <td>{{ vote.chosen.name }}</td>
93
+ <td>{{ vote.rejected.name }}</td>
94
+ <td>
95
+ <div class="text-truncate" title="{{ vote.text }}">
96
+ {{ vote.text }}
97
+ </div>
98
+ </td>
99
+ </tr>
100
+ {% endfor %}
101
+ </tbody>
102
+ </table>
103
+ </div>
104
+ </div>
105
+
106
+ <script>
107
+ document.addEventListener('DOMContentLoaded', function() {
108
+ const hourlyData = {{ hourly_data|safe }};
109
+
110
+ // Hourly activity chart
111
+ const hourlyActivityCtx = document.getElementById('hourlyActivityChart').getContext('2d');
112
+ new Chart(hourlyActivityCtx, {
113
+ type: 'bar',
114
+ data: {
115
+ labels: hourlyData.labels,
116
+ datasets: [{
117
+ label: 'Votes per Hour',
118
+ data: hourlyData.counts,
119
+ backgroundColor: 'rgba(80, 70, 229, 0.7)',
120
+ borderColor: 'rgba(80, 70, 229, 1)',
121
+ borderWidth: 1
122
+ }]
123
+ },
124
+ options: {
125
+ responsive: true,
126
+ maintainAspectRatio: false,
127
+ scales: {
128
+ yAxes: [{
129
+ ticks: {
130
+ beginAtZero: true,
131
+ precision: 0
132
+ }
133
+ }]
134
+ }
135
+ }
136
+ });
137
+ });
138
+ </script>
139
+ {% endblock %}
templates/admin/base.html ADDED
@@ -0,0 +1,539 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Admin Panel - TTS Arena{% endblock %}
4
+
5
+ {% block extra_head %}
6
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.min.css">
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.bundle.min.js"></script>
8
+ <style>
9
+ .admin-container {
10
+ width: 100%;
11
+ }
12
+
13
+ /* Horizontal navigation tabs */
14
+ .admin-nav {
15
+ display: flex;
16
+ overflow-x: auto;
17
+ white-space: nowrap;
18
+ margin-bottom: 24px;
19
+ padding-bottom: 8px;
20
+ border-bottom: 1px solid var(--border-color);
21
+ -ms-overflow-style: none; /* Hide scrollbar IE and Edge */
22
+ scrollbar-width: none; /* Hide scrollbar Firefox */
23
+ }
24
+
25
+ /* Hide scrollbar for Chrome, Safari and Opera */
26
+ .admin-nav::-webkit-scrollbar {
27
+ display: none;
28
+ }
29
+
30
+ .admin-nav-item {
31
+ display: flex;
32
+ align-items: center;
33
+ padding: 10px 16px;
34
+ margin-right: 8px;
35
+ border-radius: var(--radius);
36
+ cursor: pointer;
37
+ transition: all 0.2s;
38
+ color: var(--text-color);
39
+ text-decoration: none;
40
+ font-size: 14px;
41
+ position: relative;
42
+ }
43
+
44
+ .admin-nav-item.active {
45
+ color: var(--primary-color);
46
+ font-weight: 500;
47
+ }
48
+
49
+ .admin-nav-item.active::after {
50
+ content: '';
51
+ position: absolute;
52
+ bottom: -9px;
53
+ left: 0;
54
+ width: 100%;
55
+ height: 3px;
56
+ background-color: var(--primary-color);
57
+ border-radius: 3px 3px 0 0;
58
+ }
59
+
60
+ .admin-nav-item:hover:not(.active) {
61
+ background-color: rgba(0, 0, 0, 0.05);
62
+ }
63
+
64
+ .admin-nav-item svg {
65
+ margin-right: 8px;
66
+ width: 16px;
67
+ height: 16px;
68
+ }
69
+
70
+ .admin-content {
71
+ width: 100%;
72
+ padding: 20px;
73
+ }
74
+
75
+ .admin-header {
76
+ display: flex;
77
+ justify-content: space-between;
78
+ align-items: center;
79
+ margin-bottom: 24px;
80
+ }
81
+
82
+ .admin-title {
83
+ font-size: 24px;
84
+ font-weight: 600;
85
+ color: var(--primary-color);
86
+ }
87
+
88
+ .admin-card {
89
+ background-color: white;
90
+ border-radius: var(--radius);
91
+ box-shadow: var(--shadow);
92
+ padding: 20px;
93
+ margin-bottom: 24px;
94
+ overflow: auto; /* Add horizontal scrolling for content */
95
+ }
96
+
97
+ .admin-card-header {
98
+ display: flex;
99
+ justify-content: space-between;
100
+ align-items: center;
101
+ margin-bottom: 16px;
102
+ }
103
+
104
+ .admin-card-title {
105
+ font-size: 18px;
106
+ font-weight: 600;
107
+ color: var(--text-color);
108
+ }
109
+
110
+ .admin-stats {
111
+ display: grid;
112
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
113
+ gap: 16px;
114
+ margin-bottom: 24px;
115
+ }
116
+
117
+ .stat-card {
118
+ background-color: white;
119
+ border-radius: var(--radius);
120
+ box-shadow: var(--shadow);
121
+ padding: 16px;
122
+ text-align: center;
123
+ }
124
+
125
+ .stat-title {
126
+ font-size: 14px;
127
+ color: #666;
128
+ margin-bottom: 8px;
129
+ }
130
+
131
+ .stat-value {
132
+ font-size: 24px;
133
+ font-weight: 600;
134
+ color: var(--primary-color);
135
+ }
136
+
137
+ /* Improved table styles with responsiveness */
138
+ .table-responsive {
139
+ width: 100%;
140
+ overflow-x: auto;
141
+ -webkit-overflow-scrolling: touch;
142
+ margin-bottom: 1rem;
143
+ }
144
+
145
+ .admin-table {
146
+ width: 100%;
147
+ border-collapse: separate;
148
+ border-spacing: 0;
149
+ border-radius: var(--radius);
150
+ overflow: hidden;
151
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
152
+ border: 1px solid var(--border-color);
153
+ min-width: 600px; /* Ensures table doesn't get too squished */
154
+ }
155
+
156
+ .admin-table th, .admin-table td {
157
+ padding: 12px 16px;
158
+ text-align: left;
159
+ border-bottom: 1px solid var(--border-color);
160
+ }
161
+
162
+ .admin-table th {
163
+ font-weight: 600;
164
+ background-color: var(--secondary-color);
165
+ position: sticky;
166
+ top: 0;
167
+ z-index: 1;
168
+ font-size: 13px;
169
+ }
170
+
171
+ .admin-table tr:last-child td {
172
+ border-bottom: none;
173
+ }
174
+
175
+ .admin-table tr:hover {
176
+ background-color: rgba(0, 0, 0, 0.02);
177
+ }
178
+
179
+ /* Action buttons in tables */
180
+ .action-btn {
181
+ display: inline-block;
182
+ padding: 6px 12px;
183
+ font-size: 13px;
184
+ border-radius: var(--radius);
185
+ text-decoration: none;
186
+ color: var(--text-color);
187
+ background-color: var(--secondary-color);
188
+ border: 1px solid var(--border-color);
189
+ transition: all 0.2s;
190
+ }
191
+
192
+ .action-btn:hover {
193
+ background-color: #e0e0e0;
194
+ }
195
+
196
+ /* Enhanced form styles */
197
+ .admin-form {
198
+ max-width: 700px;
199
+ }
200
+
201
+ .form-group {
202
+ margin-bottom: 20px;
203
+ }
204
+
205
+ .form-group label {
206
+ display: block;
207
+ margin-bottom: 8px;
208
+ font-weight: 500;
209
+ color: var(--text-color);
210
+ }
211
+
212
+ .form-group small {
213
+ display: block;
214
+ margin-top: 4px;
215
+ color: #666;
216
+ font-size: 12px;
217
+ }
218
+
219
+ .form-control {
220
+ width: 100%;
221
+ padding: 12px;
222
+ border: 1px solid var(--border-color);
223
+ border-radius: var(--radius);
224
+ font-family: 'Inter', sans-serif;
225
+ background-color: white;
226
+ transition: border-color 0.2s, box-shadow 0.2s;
227
+ }
228
+
229
+ .form-control:focus {
230
+ border-color: var(--primary-color);
231
+ box-shadow: 0 0 0 2px rgba(80, 70, 229, 0.25);
232
+ outline: none;
233
+ }
234
+
235
+ /* Custom checkbox styles */
236
+ .form-check {
237
+ display: flex;
238
+ align-items: center;
239
+ margin-bottom: 16px;
240
+ position: relative;
241
+ padding-left: 30px;
242
+ cursor: pointer;
243
+ }
244
+
245
+ .form-check input {
246
+ position: absolute;
247
+ opacity: 0;
248
+ cursor: pointer;
249
+ height: 0;
250
+ width: 0;
251
+ }
252
+
253
+ .form-check label {
254
+ margin-bottom: 0;
255
+ cursor: pointer;
256
+ }
257
+
258
+ .checkmark {
259
+ position: absolute;
260
+ top: 2px;
261
+ left: 0;
262
+ height: 18px;
263
+ width: 18px;
264
+ background-color: white;
265
+ border: 1px solid var(--border-color);
266
+ border-radius: 4px;
267
+ }
268
+
269
+ .form-check:hover input ~ .checkmark {
270
+ background-color: #f5f5f5;
271
+ }
272
+
273
+ .form-check input:checked ~ .checkmark {
274
+ background-color: var(--primary-color);
275
+ border-color: var(--primary-color);
276
+ }
277
+
278
+ .checkmark:after {
279
+ content: "";
280
+ position: absolute;
281
+ display: none;
282
+ }
283
+
284
+ .form-check input:checked ~ .checkmark:after {
285
+ display: block;
286
+ }
287
+
288
+ .form-check .checkmark:after {
289
+ left: 6px;
290
+ top: 2px;
291
+ width: 4px;
292
+ height: 9px;
293
+ border: solid white;
294
+ border-width: 0 2px 2px 0;
295
+ transform: rotate(45deg);
296
+ }
297
+
298
+ .user-info {
299
+ background-color: var(--light-gray);
300
+ padding: 16px;
301
+ border-radius: var(--radius);
302
+ margin-bottom: 16px;
303
+ border: 1px solid var(--border-color);
304
+ }
305
+
306
+ .user-info p {
307
+ margin-bottom: 8px;
308
+ }
309
+
310
+ .btn-primary {
311
+ background-color: var(--primary-color);
312
+ color: white;
313
+ border: none;
314
+ padding: 12px 20px;
315
+ border-radius: var(--radius);
316
+ cursor: pointer;
317
+ font-weight: 500;
318
+ text-decoration: none;
319
+ transition: background-color 0.2s;
320
+ }
321
+
322
+ .btn-primary:hover {
323
+ background-color: #4038c7;
324
+ }
325
+
326
+ .btn-secondary {
327
+ background-color: var(--secondary-color);
328
+ color: var(--text-color);
329
+ border: 1px solid var(--border-color);
330
+ padding: 12px 20px;
331
+ border-radius: var(--radius);
332
+ cursor: pointer;
333
+ font-weight: 500;
334
+ text-decoration: none;
335
+ transition: background-color 0.2s;
336
+ }
337
+
338
+ .btn-secondary:hover {
339
+ background-color: #e0e0e0;
340
+ }
341
+
342
+ /* Badge styles */
343
+ .badge {
344
+ display: inline-block;
345
+ padding: 4px 8px;
346
+ border-radius: 4px;
347
+ font-size: 12px;
348
+ font-weight: 500;
349
+ }
350
+
351
+ .badge-primary {
352
+ background-color: var(--primary-color);
353
+ color: white;
354
+ }
355
+
356
+ .badge-secondary {
357
+ background-color: var(--secondary-color);
358
+ color: var(--text-color);
359
+ }
360
+
361
+ .pagination {
362
+ display: flex;
363
+ justify-content: center;
364
+ list-style: none;
365
+ margin-top: 24px;
366
+ }
367
+
368
+ .pagination li {
369
+ margin: 0 4px;
370
+ }
371
+
372
+ .pagination li a {
373
+ display: block;
374
+ padding: 8px 12px;
375
+ border: 1px solid var(--border-color);
376
+ border-radius: var(--radius);
377
+ color: var(--text-color);
378
+ text-decoration: none;
379
+ }
380
+
381
+ .pagination li.active a {
382
+ background-color: var(--primary-color);
383
+ color: white;
384
+ border-color: var(--primary-color);
385
+ }
386
+
387
+ /* Responsive adjustments */
388
+ @media (max-width: 768px) {
389
+ .admin-content {
390
+ padding: 16px 12px;
391
+ }
392
+
393
+ .admin-stats {
394
+ grid-template-columns: 1fr 1fr;
395
+ }
396
+
397
+ .admin-header {
398
+ flex-direction: column;
399
+ align-items: flex-start;
400
+ gap: 12px;
401
+ }
402
+
403
+ .admin-card {
404
+ padding: 15px 10px;
405
+ }
406
+
407
+ .admin-table {
408
+ font-size: 13px;
409
+ }
410
+
411
+ .admin-table th, .admin-table td {
412
+ padding: 8px 10px;
413
+ }
414
+
415
+ .action-btn {
416
+ padding: 4px 8px;
417
+ font-size: 12px;
418
+ }
419
+
420
+ .btn-primary, .btn-secondary {
421
+ padding: 8px 16px;
422
+ font-size: 14px;
423
+ display: block;
424
+ width: 100%;
425
+ text-align: center;
426
+ margin-bottom: 8px;
427
+ }
428
+ }
429
+
430
+ /* Dark mode adjustments */
431
+ @media (prefers-color-scheme: dark) {
432
+ .admin-card, .stat-card {
433
+ background-color: var(--light-gray);
434
+ }
435
+
436
+ .admin-table th {
437
+ background-color: rgba(80, 70, 229, 0.1);
438
+ }
439
+
440
+ .admin-table tr:hover {
441
+ background-color: rgba(255, 255, 255, 0.05);
442
+ }
443
+
444
+ .form-control {
445
+ background-color: var(--light-gray);
446
+ color: var(--text-color);
447
+ border-color: rgba(255, 255, 255, 0.1);
448
+ }
449
+
450
+ .checkmark {
451
+ background-color: var(--light-gray);
452
+ border-color: rgba(255, 255, 255, 0.2);
453
+ }
454
+
455
+ .form-check:hover input ~ .checkmark {
456
+ background-color: rgba(255, 255, 255, 0.1);
457
+ }
458
+
459
+ .action-btn {
460
+ background-color: rgba(255, 255, 255, 0.1);
461
+ border-color: rgba(255, 255, 255, 0.15);
462
+ }
463
+
464
+ .action-btn:hover {
465
+ background-color: rgba(255, 255, 255, 0.15);
466
+ }
467
+
468
+ .btn-secondary {
469
+ background-color: rgba(255, 255, 255, 0.1);
470
+ border-color: rgba(255, 255, 255, 0.15);
471
+ }
472
+
473
+ .btn-secondary:hover {
474
+ background-color: rgba(255, 255, 255, 0.15);
475
+ }
476
+
477
+ .btn-primary:hover {
478
+ background-color: #5d51ff;
479
+ }
480
+ }
481
+
482
+ .user-detail-value {
483
+ flex: 1;
484
+ }
485
+
486
+ /* Truncation utility class */
487
+ .text-truncate {
488
+ max-width: 300px;
489
+ white-space: nowrap;
490
+ overflow: hidden;
491
+ text-overflow: ellipsis;
492
+ }
493
+
494
+ @media (max-width: 576px) {
495
+ .text-truncate {
496
+ max-width: 150px;
497
+ }
498
+
499
+ .admin-stats {
500
+ grid-template-columns: 1fr;
501
+ }
502
+ }
503
+ </style>
504
+ {% endblock %}
505
+
506
+ {% block content %}
507
+ <div class="admin-container">
508
+ <nav class="admin-nav">
509
+ <a href="{{ url_for('admin.index') }}" class="admin-nav-item {% if request.endpoint == 'admin.index' %}active{% endif %}">
510
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>
511
+ Dashboard
512
+ </a>
513
+ <a href="{{ url_for('admin.models') }}" class="admin-nav-item {% if request.endpoint in ['admin.models', 'admin.edit_model', 'admin.add_model'] %}active{% endif %}">
514
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1v3M12 20v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M1 12h3M20 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/></svg>
515
+ Models
516
+ </a>
517
+ <a href="{{ url_for('admin.users') }}" class="admin-nav-item {% if request.endpoint == 'admin.users' or request.endpoint == 'admin.user_detail' %}active{% endif %}">
518
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
519
+ Users
520
+ </a>
521
+ <a href="{{ url_for('admin.votes') }}" class="admin-nav-item {% if request.endpoint == 'admin.votes' %}active{% endif %}">
522
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
523
+ Votes
524
+ </a>
525
+ <a href="{{ url_for('admin.statistics') }}" class="admin-nav-item {% if request.endpoint == 'admin.statistics' %}active{% endif %}">
526
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 12V8"/><path d="M13 12v-2"/><path d="M8 12v-5"/></svg>
527
+ Statistics
528
+ </a>
529
+ <a href="{{ url_for('admin.activity') }}" class="admin-nav-item {% if request.endpoint == 'admin.activity' %}active{% endif %}">
530
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8v8h8v-8z"/><path d="M3 21V3h18v9"/><path d="M12 3v6H3"/></svg>
531
+ Activity
532
+ </a>
533
+ </nav>
534
+
535
+ <div class="admin-content">
536
+ {% block admin_content %}{% endblock %}
537
+ </div>
538
+ </div>
539
+ {% endblock %}
templates/admin/edit_model.html ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">Edit Model</div>
6
+ <a href="{{ url_for('admin.models') }}" class="btn-secondary">Back to Models</a>
7
+ </div>
8
+
9
+ <div class="admin-card">
10
+ <form method="POST" class="admin-form">
11
+ <div class="form-group">
12
+ <label for="id">Model ID</label>
13
+ <input type="text" id="id" name="id" class="form-control" value="{{ model.id }}" readonly>
14
+ </div>
15
+
16
+ <div class="form-group">
17
+ <label for="name">Model Name</label>
18
+ <input type="text" id="name" name="name" class="form-control" value="{{ model.name }}" required>
19
+ </div>
20
+
21
+ <div class="form-group">
22
+ <label for="model_type">Model Type</label>
23
+ <input type="text" id="model_type" name="model_type" class="form-control" value="{{ model.model_type }}" readonly>
24
+ </div>
25
+
26
+ <div class="form-group">
27
+ <label for="model_url">Model URL</label>
28
+ <input type="url" id="model_url" name="model_url" class="form-control" value="{{ model.model_url or '' }}">
29
+ <small>Optional: URL to the model's page/repository</small>
30
+ </div>
31
+
32
+ <div class="form-group">
33
+ <label for="current_elo">Current ELO Score</label>
34
+ <input type="number" id="current_elo" name="current_elo" class="form-control" value="{{ model.current_elo }}" readonly>
35
+ <small>ELO score is calculated automatically from votes</small>
36
+ </div>
37
+
38
+ <div class="form-check">
39
+ <input type="checkbox" id="is_active" name="is_active" {% if model.is_active %}checked{% endif %}>
40
+ <span class="checkmark"></span>
41
+ <label for="is_active">Active (available for voting)</label>
42
+ </div>
43
+
44
+ <div class="form-check">
45
+ <input type="checkbox" id="is_open" name="is_open" {% if model.is_open %}checked{% endif %}>
46
+ <span class="checkmark"></span>
47
+ <label for="is_open">Open Source</label>
48
+ </div>
49
+
50
+ <div class="form-group">
51
+ <label>Statistics</label>
52
+ <div class="user-info">
53
+ <p><strong>Matches:</strong> {{ model.match_count }}</p>
54
+ <p><strong>Wins:</strong> {{ model.win_count }}</p>
55
+ <p><strong>Win Rate:</strong> {{ model.win_rate|round(2) }}%</p>
56
+ </div>
57
+ </div>
58
+
59
+ <button type="submit" class="btn-primary">Save Changes</button>
60
+ </form>
61
+ </div>
62
+ {% endblock %}
templates/admin/index.html ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">Dashboard</div>
6
+ </div>
7
+
8
+ <div class="admin-stats">
9
+ <div class="stat-card">
10
+ <div class="stat-title">Total Users</div>
11
+ <div class="stat-value">{{ stats.total_users }}</div>
12
+ </div>
13
+ <div class="stat-card">
14
+ <div class="stat-title">Total Votes</div>
15
+ <div class="stat-value">{{ stats.total_votes }}</div>
16
+ </div>
17
+ <div class="stat-card">
18
+ <div class="stat-title">TTS Votes</div>
19
+ <div class="stat-value">{{ stats.tts_votes }}</div>
20
+ </div>
21
+ <div class="stat-card">
22
+ <div class="stat-title">Conversational Votes</div>
23
+ <div class="stat-value">{{ stats.conversational_votes }}</div>
24
+ </div>
25
+ <div class="stat-card">
26
+ <div class="stat-title">TTS Models</div>
27
+ <div class="stat-value">{{ stats.tts_models }}</div>
28
+ </div>
29
+ <div class="stat-card">
30
+ <div class="stat-title">Conversational Models</div>
31
+ <div class="stat-value">{{ stats.conversational_models }}</div>
32
+ </div>
33
+ </div>
34
+
35
+ <div class="admin-card">
36
+ <div class="admin-card-header">
37
+ <div class="admin-card-title">Daily Votes (Last 30 Days)</div>
38
+ </div>
39
+ <canvas id="votesChart" height="200"></canvas>
40
+ </div>
41
+
42
+ <div class="admin-card">
43
+ <div class="admin-card-header">
44
+ <div class="admin-card-title">Top TTS Models</div>
45
+ </div>
46
+ <div class="table-responsive">
47
+ <table class="admin-table">
48
+ <thead>
49
+ <tr>
50
+ <th>Rank</th>
51
+ <th>Model</th>
52
+ <th>ELO Score</th>
53
+ <th>Win Rate</th>
54
+ <th>Total Matches</th>
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ {% for model in top_tts_models %}
59
+ <tr>
60
+ <td>{{ loop.index }}</td>
61
+ <td>{{ model.name }}</td>
62
+ <td>{{ model.current_elo|int }}</td>
63
+ <td>{{ model.win_rate|round }}%</td>
64
+ <td>{{ model.match_count }}</td>
65
+ </tr>
66
+ {% endfor %}
67
+ </tbody>
68
+ </table>
69
+ </div>
70
+ </div>
71
+
72
+ <div class="admin-card">
73
+ <div class="admin-card-header">
74
+ <div class="admin-card-title">Top Conversational Models</div>
75
+ </div>
76
+ <div class="table-responsive">
77
+ <table class="admin-table">
78
+ <thead>
79
+ <tr>
80
+ <th>Rank</th>
81
+ <th>Model</th>
82
+ <th>ELO Score</th>
83
+ <th>Win Rate</th>
84
+ <th>Total Matches</th>
85
+ </tr>
86
+ </thead>
87
+ <tbody>
88
+ {% for model in top_conversational_models %}
89
+ <tr>
90
+ <td>{{ loop.index }}</td>
91
+ <td>{{ model.name }}</td>
92
+ <td>{{ model.current_elo|int }}</td>
93
+ <td>{{ model.win_rate|round }}%</td>
94
+ <td>{{ model.match_count }}</td>
95
+ </tr>
96
+ {% endfor %}
97
+ </tbody>
98
+ </table>
99
+ </div>
100
+ </div>
101
+
102
+ <div class="admin-row">
103
+ <div class="admin-card">
104
+ <div class="admin-card-header">
105
+ <div class="admin-card-title">Recent Votes</div>
106
+ <a href="{{ url_for('admin.votes') }}" class="btn-secondary">View All</a>
107
+ </div>
108
+ <div class="table-responsive">
109
+ <table class="admin-table">
110
+ <thead>
111
+ <tr>
112
+ <th>Date</th>
113
+ <th>Type</th>
114
+ <th>User</th>
115
+ <th>Chosen Model</th>
116
+ <th>Rejected Model</th>
117
+ </tr>
118
+ </thead>
119
+ <tbody>
120
+ {% for vote in recent_votes %}
121
+ <tr>
122
+ <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
123
+ <td>{{ vote.model_type }}</td>
124
+ <td>
125
+ {% if vote.user %}
126
+ <a href="{{ url_for('admin.user_detail', user_id=vote.user.id) }}">{{ vote.user.username }}</a>
127
+ {% else %}
128
+ Anonymous
129
+ {% endif %}
130
+ </td>
131
+ <td>{{ vote.chosen.name }}</td>
132
+ <td>{{ vote.rejected.name }}</td>
133
+ </tr>
134
+ {% endfor %}
135
+ </tbody>
136
+ </table>
137
+ </div>
138
+ </div>
139
+ </div>
140
+
141
+ <div class="admin-row">
142
+ <div class="admin-card">
143
+ <div class="admin-card-header">
144
+ <div class="admin-card-title">Recent Users</div>
145
+ <a href="{{ url_for('admin.users') }}" class="btn-secondary">View All</a>
146
+ </div>
147
+ <div class="table-responsive">
148
+ <table class="admin-table">
149
+ <thead>
150
+ <tr>
151
+ <th>Username</th>
152
+ <th>Join Date</th>
153
+ <th>Actions</th>
154
+ </tr>
155
+ </thead>
156
+ <tbody>
157
+ {% for user in recent_users %}
158
+ <tr>
159
+ <td>{{ user.username }}</td>
160
+ <td>{{ user.join_date.strftime('%Y-%m-%d %H:%M') }}</td>
161
+ <td>
162
+ <a href="{{ url_for('admin.user_detail', user_id=user.id) }}" class="btn-secondary">View Details</a>
163
+ </td>
164
+ </tr>
165
+ {% endfor %}
166
+ </tbody>
167
+ </table>
168
+ </div>
169
+ </div>
170
+ </div>
171
+
172
+ <script>
173
+ document.addEventListener('DOMContentLoaded', function() {
174
+ const votesData = {{ daily_votes_data|safe }};
175
+
176
+ // Daily votes chart
177
+ const votesCtx = document.getElementById('votesChart').getContext('2d');
178
+ new Chart(votesCtx, {
179
+ type: 'line',
180
+ data: {
181
+ labels: votesData.labels,
182
+ datasets: [{
183
+ label: 'Daily Votes',
184
+ data: votesData.counts,
185
+ backgroundColor: 'rgba(80, 70, 229, 0.1)',
186
+ borderColor: 'rgba(80, 70, 229, 1)',
187
+ borderWidth: 2,
188
+ tension: 0.3,
189
+ fill: true,
190
+ pointRadius: 3,
191
+ pointBackgroundColor: '#5046e5'
192
+ }]
193
+ },
194
+ options: {
195
+ responsive: true,
196
+ maintainAspectRatio: false,
197
+ scales: {
198
+ yAxes: [{
199
+ ticks: {
200
+ beginAtZero: true,
201
+ precision: 0
202
+ }
203
+ }]
204
+ },
205
+ tooltips: {
206
+ mode: 'index',
207
+ intersect: false
208
+ }
209
+ }
210
+ });
211
+ });
212
+ </script>
213
+ {% endblock %}
templates/admin/models.html ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">Manage Models</div>
6
+ </div>
7
+
8
+ <div class="admin-card">
9
+ <div class="admin-card-header">
10
+ <div class="admin-card-title">TTS Models</div>
11
+ </div>
12
+ <div class="table-responsive">
13
+ <table class="admin-table">
14
+ <thead>
15
+ <tr>
16
+ <th>ID</th>
17
+ <th>Name</th>
18
+ <th>ELO Score</th>
19
+ <th>Matches</th>
20
+ <th>Active</th>
21
+ <th>Open Source</th>
22
+ <th>Actions</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ {% for model in tts_models %}
27
+ <tr>
28
+ <td>{{ model.id }}</td>
29
+ <td>{{ model.name }}</td>
30
+ <td>{{ model.current_elo|int }}</td>
31
+ <td>{{ model.match_count }}</td>
32
+ <td>{{ "Yes" if model.is_active else "No" }}</td>
33
+ <td>{{ "Yes" if model.is_open else "No" }}</td>
34
+ <td>
35
+ <a href="{{ url_for('admin.edit_model', model_id=model.id) }}" class="action-btn">Edit</a>
36
+ </td>
37
+ </tr>
38
+ {% endfor %}
39
+ </tbody>
40
+ </table>
41
+ </div>
42
+ </div>
43
+
44
+ <div class="admin-card">
45
+ <div class="admin-card-header">
46
+ <div class="admin-card-title">Conversational Models</div>
47
+ </div>
48
+ <div class="table-responsive">
49
+ <table class="admin-table">
50
+ <thead>
51
+ <tr>
52
+ <th>ID</th>
53
+ <th>Name</th>
54
+ <th>ELO Score</th>
55
+ <th>Matches</th>
56
+ <th>Active</th>
57
+ <th>Open Source</th>
58
+ <th>Actions</th>
59
+ </tr>
60
+ </thead>
61
+ <tbody>
62
+ {% for model in conversational_models %}
63
+ <tr>
64
+ <td>{{ model.id }}</td>
65
+ <td>{{ model.name }}</td>
66
+ <td>{{ model.current_elo|int }}</td>
67
+ <td>{{ model.match_count }}</td>
68
+ <td>{{ "Yes" if model.is_active else "No" }}</td>
69
+ <td>{{ "Yes" if model.is_open else "No" }}</td>
70
+ <td>
71
+ <a href="{{ url_for('admin.edit_model', model_id=model.id) }}" class="action-btn">Edit</a>
72
+ </td>
73
+ </tr>
74
+ {% endfor %}
75
+ </tbody>
76
+ </table>
77
+ </div>
78
+ </div>
79
+ {% endblock %}
templates/admin/statistics.html ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">Statistics</div>
6
+ </div>
7
+
8
+ <div class="admin-card">
9
+ <div class="admin-card-header">
10
+ <div class="admin-card-title">Daily Votes by Model Type (Last 30 Days)</div>
11
+ </div>
12
+ <canvas id="dailyVotesChart" height="250"></canvas>
13
+ </div>
14
+
15
+ <div class="admin-card">
16
+ <div class="admin-card-header">
17
+ <div class="admin-card-title">New Users by Month</div>
18
+ </div>
19
+ <canvas id="monthlyUsersChart" height="250"></canvas>
20
+ </div>
21
+
22
+ <div class="admin-card">
23
+ <div class="admin-card-header">
24
+ <div class="admin-card-title">Model ELO History</div>
25
+ </div>
26
+ <canvas id="modelHistoryChart" height="300"></canvas>
27
+ </div>
28
+
29
+ <script>
30
+ document.addEventListener('DOMContentLoaded', function() {
31
+ const chartData = {{ chart_data|safe }};
32
+
33
+ // Daily votes by model type
34
+ const dailyVotesCtx = document.getElementById('dailyVotesChart').getContext('2d');
35
+ new Chart(dailyVotesCtx, {
36
+ type: 'line',
37
+ data: {
38
+ labels: chartData.dailyVotes.labels,
39
+ datasets: [
40
+ {
41
+ label: 'TTS Votes',
42
+ data: chartData.dailyVotes.ttsCounts,
43
+ backgroundColor: 'rgba(80, 70, 229, 0.1)',
44
+ borderColor: 'rgba(80, 70, 229, 1)',
45
+ borderWidth: 2,
46
+ tension: 0.3,
47
+ fill: true,
48
+ pointRadius: 2,
49
+ pointBackgroundColor: '#5046e5'
50
+ },
51
+ {
52
+ label: 'Conversational Votes',
53
+ data: chartData.dailyVotes.convCounts,
54
+ backgroundColor: 'rgba(236, 72, 153, 0.1)',
55
+ borderColor: 'rgba(236, 72, 153, 1)',
56
+ borderWidth: 2,
57
+ tension: 0.3,
58
+ fill: true,
59
+ pointRadius: 2,
60
+ pointBackgroundColor: '#ec4899'
61
+ }
62
+ ]
63
+ },
64
+ options: {
65
+ responsive: true,
66
+ maintainAspectRatio: false,
67
+ scales: {
68
+ yAxes: [{
69
+ ticks: {
70
+ beginAtZero: true,
71
+ precision: 0
72
+ }
73
+ }]
74
+ },
75
+ tooltips: {
76
+ mode: 'index',
77
+ intersect: false
78
+ }
79
+ }
80
+ });
81
+
82
+ // Monthly users chart
83
+ const monthlyUsersCtx = document.getElementById('monthlyUsersChart').getContext('2d');
84
+ new Chart(monthlyUsersCtx, {
85
+ type: 'bar',
86
+ data: {
87
+ labels: chartData.monthlyUsers.labels,
88
+ datasets: [{
89
+ label: 'New Users',
90
+ data: chartData.monthlyUsers.counts,
91
+ backgroundColor: 'rgba(16, 185, 129, 0.7)',
92
+ borderColor: 'rgba(16, 185, 129, 1)',
93
+ borderWidth: 1
94
+ }]
95
+ },
96
+ options: {
97
+ responsive: true,
98
+ maintainAspectRatio: false,
99
+ scales: {
100
+ yAxes: [{
101
+ ticks: {
102
+ beginAtZero: true,
103
+ precision: 0
104
+ }
105
+ }]
106
+ }
107
+ }
108
+ });
109
+
110
+ // Model ELO history chart
111
+ const modelHistoryCtx = document.getElementById('modelHistoryChart').getContext('2d');
112
+
113
+ // Prepare datasets for each model
114
+ const modelDatasets = [];
115
+ const colors = [
116
+ { backgroundColor: 'rgba(80, 70, 229, 0.1)', borderColor: 'rgba(80, 70, 229, 1)' },
117
+ { backgroundColor: 'rgba(236, 72, 153, 0.1)', borderColor: 'rgba(236, 72, 153, 1)' },
118
+ { backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 1)' },
119
+ { backgroundColor: 'rgba(245, 158, 11, 0.1)', borderColor: 'rgba(245, 158, 11, 1)' },
120
+ { backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 1)' }
121
+ ];
122
+
123
+ let colorIndex = 0;
124
+ for (const modelName in chartData.modelHistory) {
125
+ const model = chartData.modelHistory[modelName];
126
+ modelDatasets.push({
127
+ label: modelName,
128
+ data: model.scores,
129
+ backgroundColor: colors[colorIndex % colors.length].backgroundColor,
130
+ borderColor: colors[colorIndex % colors.length].borderColor,
131
+ borderWidth: 2,
132
+ tension: 0.3,
133
+ fill: false,
134
+ pointRadius: 1
135
+ });
136
+ colorIndex++;
137
+ }
138
+
139
+ // If we have any model data, create the chart
140
+ if (modelDatasets.length > 0) {
141
+ // Get timestamps from the first model (they should all have the same timepoints)
142
+ const firstModel = Object.values(chartData.modelHistory)[0];
143
+
144
+ new Chart(modelHistoryCtx, {
145
+ type: 'line',
146
+ data: {
147
+ labels: firstModel.timestamps,
148
+ datasets: modelDatasets
149
+ },
150
+ options: {
151
+ responsive: true,
152
+ maintainAspectRatio: false,
153
+ scales: {
154
+ yAxes: [{
155
+ ticks: {
156
+ beginAtZero: false
157
+ }
158
+ }]
159
+ },
160
+ tooltips: {
161
+ mode: 'index',
162
+ intersect: false
163
+ }
164
+ }
165
+ });
166
+ } else {
167
+ // If no model data, show a message
168
+ document.getElementById('modelHistoryChart').parentNode.innerHTML =
169
+ '<div style="text-align: center; padding: 20px;">No model history data available yet.</div>';
170
+ }
171
+ });
172
+ </script>
173
+ {% endblock %}
templates/admin/user_detail.html ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">User Details</div>
6
+ <a href="{{ url_for('admin.users') }}" class="btn-secondary">Back to Users</a>
7
+ </div>
8
+
9
+ <div class="admin-card">
10
+ <div class="admin-card-header">
11
+ <div class="admin-card-title">User Information</div>
12
+ </div>
13
+ <div class="user-info">
14
+ <div class="user-detail-row">
15
+ <div class="user-detail-label">Username:</div>
16
+ <div class="user-detail-value">{{ user.username }}</div>
17
+ </div>
18
+ <div class="user-detail-row">
19
+ <div class="user-detail-label">Hugging Face ID:</div>
20
+ <div class="user-detail-value">{{ user.hf_id }}</div>
21
+ </div>
22
+ <div class="user-detail-row">
23
+ <div class="user-detail-label">Join Date:</div>
24
+ <div class="user-detail-value">{{ user.join_date.strftime('%Y-%m-%d %H:%M:%S') }}</div>
25
+ </div>
26
+ </div>
27
+
28
+ <div class="user-stats">
29
+ <div class="stat-card">
30
+ <div class="stat-title">Total Votes</div>
31
+ <div class="stat-value">{{ total_votes }}</div>
32
+ </div>
33
+ <div class="stat-card">
34
+ <div class="stat-title">TTS Votes</div>
35
+ <div class="stat-value">{{ tts_votes }}</div>
36
+ </div>
37
+ <div class="stat-card">
38
+ <div class="stat-title">Conversational Votes</div>
39
+ <div class="stat-value">{{ conversational_votes }}</div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+
44
+ {% if favorite_models %}
45
+ <div class="admin-card">
46
+ <div class="admin-card-header">
47
+ <div class="admin-card-title">Favorite Models</div>
48
+ </div>
49
+ <div class="table-responsive">
50
+ <table class="admin-table">
51
+ <thead>
52
+ <tr>
53
+ <th>Model</th>
54
+ <th>Votes</th>
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ {% for model in favorite_models %}
59
+ <tr>
60
+ <td>{{ model.name }}</td>
61
+ <td>{{ model.count }}</td>
62
+ </tr>
63
+ {% endfor %}
64
+ </tbody>
65
+ </table>
66
+ </div>
67
+ </div>
68
+ {% endif %}
69
+
70
+ {% if recent_votes %}
71
+ <div class="admin-card">
72
+ <div class="admin-card-header">
73
+ <div class="admin-card-title">Recent Votes</div>
74
+ </div>
75
+ <div class="table-responsive">
76
+ <table class="admin-table">
77
+ <thead>
78
+ <tr>
79
+ <th>Date</th>
80
+ <th>Type</th>
81
+ <th>Chosen Model</th>
82
+ <th>Rejected Model</th>
83
+ <th>Text</th>
84
+ </tr>
85
+ </thead>
86
+ <tbody>
87
+ {% for vote in recent_votes %}
88
+ <tr>
89
+ <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
90
+ <td>{{ vote.model_type }}</td>
91
+ <td>{{ vote.chosen.name }}</td>
92
+ <td>{{ vote.rejected.name }}</td>
93
+ <td>
94
+ <div class="text-truncate" title="{{ vote.text }}">
95
+ {{ vote.text }}
96
+ </div>
97
+ </td>
98
+ </tr>
99
+ {% endfor %}
100
+ </tbody>
101
+ </table>
102
+ </div>
103
+ </div>
104
+ {% endif %}
105
+
106
+ <style>
107
+ .user-detail-row {
108
+ display: flex;
109
+ margin-bottom: 10px;
110
+ }
111
+
112
+ .user-detail-label {
113
+ font-weight: 600;
114
+ min-width: 150px;
115
+ }
116
+
117
+ .user-detail-value {
118
+ flex: 1;
119
+ }
120
+
121
+ .user-stats {
122
+ display: grid;
123
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
124
+ gap: 16px;
125
+ margin-top: 24px;
126
+ }
127
+
128
+ .text-truncate {
129
+ max-width: 300px;
130
+ white-space: nowrap;
131
+ overflow: hidden;
132
+ text-overflow: ellipsis;
133
+ }
134
+
135
+ @media (max-width: 576px) {
136
+ .user-detail-row {
137
+ flex-direction: column;
138
+ }
139
+
140
+ .user-detail-label {
141
+ margin-bottom: 4px;
142
+ }
143
+ }
144
+ </style>
145
+ {% endblock %}
templates/admin/users.html ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">Manage Users</div>
6
+ </div>
7
+
8
+ <div class="admin-card">
9
+ <div class="admin-card-header">
10
+ <div class="admin-card-title">All Users</div>
11
+ </div>
12
+ <div class="table-responsive">
13
+ <table class="admin-table">
14
+ <thead>
15
+ <tr>
16
+ <th>ID</th>
17
+ <th>Username</th>
18
+ <th>HF ID</th>
19
+ <th>Join Date</th>
20
+ <th>Admin Status</th>
21
+ <th>Actions</th>
22
+ </tr>
23
+ </thead>
24
+ <tbody>
25
+ {% for user in users %}
26
+ <tr>
27
+ <td>{{ user.id }}</td>
28
+ <td>{{ user.username }}</td>
29
+ <td>{{ user.hf_id }}</td>
30
+ <td>{{ user.join_date.strftime('%Y-%m-%d %H:%M') }}</td>
31
+ <td>
32
+ {% if g.is_admin and user.username in admin_users %}
33
+ <span class="badge badge-primary">Admin</span>
34
+ {% else %}
35
+ <span class="badge badge-secondary">User</span>
36
+ {% endif %}
37
+ </td>
38
+ <td>
39
+ <a href="{{ url_for('admin.user_detail', user_id=user.id) }}" class="action-btn">View Details</a>
40
+ </td>
41
+ </tr>
42
+ {% endfor %}
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ </div>
47
+ {% endblock %}
templates/admin/votes.html ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block admin_content %}
4
+ <div class="admin-header">
5
+ <div class="admin-title">Votes</div>
6
+ </div>
7
+
8
+ <div class="admin-card">
9
+ <div class="admin-card-header">
10
+ <div class="admin-card-title">Recent Votes</div>
11
+ </div>
12
+ <div class="table-responsive">
13
+ <table class="admin-table">
14
+ <thead>
15
+ <tr>
16
+ <th>ID</th>
17
+ <th>Date</th>
18
+ <th>Type</th>
19
+ <th>User</th>
20
+ <th>Chosen Model</th>
21
+ <th>Rejected Model</th>
22
+ <th>Text</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ {% for vote in votes %}
27
+ <tr>
28
+ <td>{{ vote.id }}</td>
29
+ <td>{{ vote.vote_date.strftime('%Y-%m-%d %H:%M') }}</td>
30
+ <td>{{ vote.model_type }}</td>
31
+ <td>
32
+ {% if vote.user %}
33
+ <a href="{{ url_for('admin.user_detail', user_id=vote.user.id) }}">{{ vote.user.username }}</a>
34
+ {% else %}
35
+ Anonymous
36
+ {% endif %}
37
+ </td>
38
+ <td>{{ vote.chosen.name }}</td>
39
+ <td>{{ vote.rejected.name }}</td>
40
+ <td>
41
+ <div class="text-truncate" title="{{ vote.text }}">
42
+ {{ vote.text }}
43
+ </div>
44
+ </td>
45
+ </tr>
46
+ {% endfor %}
47
+ </tbody>
48
+ </table>
49
+ </div>
50
+
51
+ {% if pagination.pages > 1 %}
52
+ <nav aria-label="Page navigation">
53
+ <ul class="pagination">
54
+ {% if pagination.has_prev %}
55
+ <li><a href="{{ url_for('admin.votes', page=pagination.prev_num) }}">&laquo; Previous</a></li>
56
+ {% endif %}
57
+
58
+ {% for page_num in pagination.iter_pages(left_edge=2, left_current=2, right_current=3, right_edge=2) %}
59
+ {% if page_num %}
60
+ {% if page_num == pagination.page %}
61
+ <li class="active"><a href="#">{{ page_num }}</a></li>
62
+ {% else %}
63
+ <li><a href="{{ url_for('admin.votes', page=page_num) }}">{{ page_num }}</a></li>
64
+ {% endif %}
65
+ {% else %}
66
+ <li class="disabled"><a href="#">...</a></li>
67
+ {% endif %}
68
+ {% endfor %}
69
+
70
+ {% if pagination.has_next %}
71
+ <li><a href="{{ url_for('admin.votes', page=pagination.next_num) }}">Next &raquo;</a></li>
72
+ {% endif %}
73
+ </ul>
74
+ </nav>
75
+ {% endif %}
76
+ </div>
77
+ {% endblock %}
templates/arena.html ADDED
@@ -0,0 +1,1959 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Arena - TTS Arena{% endblock %}
4
+
5
+ {% block current_page %}Arena{% endblock %}
6
+
7
+ {% block content %}
8
+ <div class="tabs">
9
+ <div class="tab active" data-tab="tts">TTS</div>
10
+ <div class="tab" data-tab="conversational">Conversational</div>
11
+ </div>
12
+
13
+ <div id="tts-tab" class="tab-content active">
14
+ <form class="input-container">
15
+ <div class="input-group">
16
+ <button type="button" class="segmented-btn random-btn" title="Roll random text">
17
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shuffle-icon lucide-shuffle">
18
+ <path d="m18 14 4 4-4 4" />
19
+ <path d="m18 2 4 4-4 4" />
20
+ <path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22" />
21
+ <path d="M2 6h1.972a4 4 0 0 1 3.6 2.2" />
22
+ <path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45" />
23
+ </svg>
24
+ </button>
25
+ <input type="text" class="text-input" placeholder="Enter text to synthesize...">
26
+ <button type="submit" class="segmented-btn synth-btn">Synthesize</button>
27
+ </div>
28
+ <button type="submit" class="mobile-synth-btn">Synthesize</button>
29
+ </form>
30
+
31
+ <div class="loading-container" style="display: none;">
32
+ <div class="loader-wrapper">
33
+ <div class="loader-animation">
34
+ <div class="sound-wave">
35
+ <span></span>
36
+ <span></span>
37
+ <span></span>
38
+ <span></span>
39
+ <span></span>
40
+ <span></span>
41
+ </div>
42
+ </div>
43
+ <div class="loader-text">Generating audio samples...</div>
44
+ <div class="loader-subtext">This may take up to 30 seconds</div>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="players-container" style="display: none;">
49
+ <div class="players-row">
50
+ <div class="player">
51
+ <div class="player-label">Model A <span class="model-name-display"></span></div>
52
+ <div class="wave-player-container" data-model="a"></div>
53
+ <button class="vote-btn" data-model="a" disabled>
54
+ Vote for A
55
+ <span class="shortcut-key">A</span>
56
+ <span class="vote-loader" style="display: none;">
57
+ <div class="vote-spinner"></div>
58
+ </span>
59
+ </button>
60
+ </div>
61
+
62
+ <div class="player">
63
+ <div class="player-label">Model B <span class="model-name-display"></span></div>
64
+ <div class="wave-player-container" data-model="b"></div>
65
+ <button class="vote-btn" data-model="b" disabled>
66
+ Vote for B
67
+ <span class="shortcut-key">B</span>
68
+ <span class="vote-loader" style="display: none;">
69
+ <div class="vote-spinner"></div>
70
+ </span>
71
+ </button>
72
+ </div>
73
+ </div>
74
+
75
+ <div class="keyboard-hint">
76
+ Press <kbd>Space</kbd> to play/pause audio, <kbd>A</kbd> or <kbd>B</kbd> to vote after listening
77
+ </div>
78
+ </div>
79
+
80
+ <div class="vote-results" style="display: none;">
81
+ <h3 class="results-heading">Vote Recorded!</h3>
82
+ <div class="results-content">
83
+ <div class="chosen-model">
84
+ <strong>You chose:</strong> <span class="chosen-model-name"></span>
85
+ </div>
86
+ <div class="rejected-model">
87
+ <strong>Over:</strong> <span class="rejected-model-name"></span>
88
+ </div>
89
+ </div>
90
+ </div>
91
+
92
+ <div class="next-round-container" style="display: none;">
93
+ <button class="next-round-btn">Next Round <span class="shortcut-key">N</span></button>
94
+ </div>
95
+ </div>
96
+
97
+ <div id="conversational-tab" class="tab-content">
98
+ <div class="podcast-container">
99
+ <div class="podcast-controls">
100
+ <button type="button" class="segmented-btn random-script-btn" title="Load random script">
101
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shuffle-icon lucide-shuffle">
102
+ <path d="m18 14 4 4-4 4" />
103
+ <path d="m18 2 4 4-4 4" />
104
+ <path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22" />
105
+ <path d="M2 6h1.972a4 4 0 0 1 3.6 2.2" />
106
+ <path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45" />
107
+ </svg>
108
+ Random Script
109
+ </button>
110
+ <button type="button" class="podcast-synth-btn">Generate Podcast</button>
111
+ </div>
112
+
113
+ <div class="podcast-script-container">
114
+ <div class="podcast-lines">
115
+ <!-- Script lines will be added here -->
116
+ </div>
117
+
118
+ <button type="button" class="add-line-btn">+ Add Line</button>
119
+
120
+ <div class="keyboard-hint podcast-keyboard-hint">
121
+ Press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> or <kbd>Alt</kbd>+<kbd>Enter</kbd> to add a new line
122
+ </div>
123
+ </div>
124
+
125
+ <div class="podcast-loading-container" style="display: none;">
126
+ <div class="loader-wrapper">
127
+ <div class="loader-animation">
128
+ <div class="sound-wave">
129
+ <span></span>
130
+ <span></span>
131
+ <span></span>
132
+ <span></span>
133
+ <span></span>
134
+ <span></span>
135
+ </div>
136
+ </div>
137
+ <div class="loader-text">Generating podcast...</div>
138
+ <div class="loader-subtext">This may take up to a minute</div>
139
+ </div>
140
+ </div>
141
+
142
+ <div class="podcast-player-container" style="display: none;">
143
+ <div class="players-row">
144
+ <div class="player">
145
+ <div class="player-label">Model A <span class="model-name-display"></span></div>
146
+ <div class="podcast-wave-player-a"></div>
147
+ <button class="vote-btn" data-model="a" disabled>
148
+ Vote for A
149
+ <span class="shortcut-key">A</span>
150
+ <span class="vote-loader" style="display: none;">
151
+ <div class="vote-spinner"></div>
152
+ </span>
153
+ </button>
154
+ </div>
155
+
156
+ <div class="player">
157
+ <div class="player-label">Model B <span class="model-name-display"></span></div>
158
+ <div class="podcast-wave-player-b"></div>
159
+ <button class="vote-btn" data-model="b" disabled>
160
+ Vote for B
161
+ <span class="shortcut-key">B</span>
162
+ <span class="vote-loader" style="display: none;">
163
+ <div class="vote-spinner"></div>
164
+ </span>
165
+ </button>
166
+ </div>
167
+ </div>
168
+
169
+ <div class="keyboard-hint">
170
+ Press <kbd>Space</kbd> to play/pause audio, <kbd>A</kbd> or <kbd>B</kbd> to vote after listening
171
+ </div>
172
+
173
+ <div class="podcast-vote-results vote-results" style="display: none;">
174
+ <h3 class="results-heading">Vote Recorded!</h3>
175
+ <div class="results-content">
176
+ <div class="chosen-model">
177
+ <strong>You chose:</strong> <span class="chosen-model-name"></span>
178
+ </div>
179
+ <div class="rejected-model">
180
+ <strong>Over:</strong> <span class="rejected-model-name"></span>
181
+ </div>
182
+ </div>
183
+ </div>
184
+
185
+ <div class="podcast-next-round-container next-round-container" style="display: none;">
186
+ <button class="podcast-next-round-btn next-round-btn">Next Round <span class="shortcut-key">N</span></button>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ {% endblock %}
192
+
193
+ {% block extra_head %}
194
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/waveplayer.css') }}">
195
+ <script src="https://unpkg.com/wavesurfer.js@6/dist/wavesurfer.min.js"></script>
196
+ <style>
197
+ .input-container {
198
+ display: flex;
199
+ flex-direction: column;
200
+ margin-bottom: 24px;
201
+ }
202
+
203
+ .input-group {
204
+ display: flex;
205
+ width: 100%;
206
+ border-radius: var(--radius);
207
+ border: 1px solid var(--border-color);
208
+ overflow: hidden;
209
+ }
210
+
211
+ /* Override base styles to remove duplicate borders */
212
+ .input-group .text-input {
213
+ flex: 1;
214
+ padding: 12px 16px;
215
+ border: none;
216
+ border-radius: 0;
217
+ font-size: 16px;
218
+ outline: none;
219
+ height: 48px;
220
+ transition: none;
221
+ }
222
+
223
+ .input-group .text-input:focus {
224
+ border: none;
225
+ outline: none;
226
+ background-color: rgba(80, 70, 229, 0.03);
227
+ }
228
+
229
+ .segmented-btn {
230
+ background-color: white;
231
+ border: none;
232
+ height: 48px;
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: center;
236
+ cursor: pointer;
237
+ transition: background-color 0.2s;
238
+ }
239
+
240
+ .random-btn {
241
+ width: 48px;
242
+ border-right: 1px solid var(--border-color);
243
+ }
244
+
245
+ .random-btn svg {
246
+ color: var(--primary-color);
247
+ }
248
+
249
+ .synth-btn {
250
+ padding: 0 24px;
251
+ font-weight: 500;
252
+ border-left: 1px solid var(--border-color);
253
+ background-color: var(--primary-color);
254
+ color: white;
255
+ font-size: 1em;
256
+ }
257
+
258
+ .synth-btn:hover {
259
+ background-color: #4038c7;
260
+ }
261
+
262
+ .random-btn:hover {
263
+ background-color: var(--light-gray);
264
+ }
265
+
266
+ .mobile-synth-btn {
267
+ display: none;
268
+ width: 100%;
269
+ padding: 12px;
270
+ margin-top: 12px;
271
+ background-color: var(--primary-color);
272
+ color: white;
273
+ border: none;
274
+ border-radius: var(--radius);
275
+ font-weight: 500;
276
+ cursor: pointer;
277
+ font-size: 1em;
278
+ }
279
+
280
+ .loading-container {
281
+ display: flex;
282
+ justify-content: center;
283
+ align-items: center;
284
+ margin: 40px 0;
285
+ }
286
+
287
+ .loader-wrapper {
288
+ text-align: center;
289
+ }
290
+
291
+ .loader-animation {
292
+ margin-bottom: 24px;
293
+ }
294
+
295
+ .loader-text {
296
+ font-size: 18px;
297
+ font-weight: 600;
298
+ margin-bottom: 8px;
299
+ color: var(--text-color);
300
+ }
301
+
302
+ .loader-subtext {
303
+ font-size: 14px;
304
+ color: #666;
305
+ }
306
+
307
+ .sound-wave {
308
+ height: 60px;
309
+ display: flex;
310
+ align-items: center;
311
+ justify-content: center;
312
+ gap: 8px;
313
+ }
314
+
315
+ .sound-wave span {
316
+ display: block;
317
+ width: 6px;
318
+ height: 20px;
319
+ background-color: var(--primary-color);
320
+ border-radius: 8px;
321
+ animation: sound-wave-animation 1.2s infinite ease-in-out;
322
+ }
323
+
324
+ .sound-wave span:nth-child(2) {
325
+ animation-delay: 0.2s;
326
+ }
327
+
328
+ .sound-wave span:nth-child(3) {
329
+ animation-delay: 0.4s;
330
+ }
331
+
332
+ .sound-wave span:nth-child(4) {
333
+ animation-delay: 0.6s;
334
+ }
335
+
336
+ .sound-wave span:nth-child(5) {
337
+ animation-delay: 0.8s;
338
+ }
339
+
340
+ .sound-wave span:nth-child(6) {
341
+ animation-delay: 1s;
342
+ }
343
+
344
+ @keyframes sound-wave-animation {
345
+ 0%, 100% {
346
+ height: 20px;
347
+ }
348
+ 50% {
349
+ height: 50px;
350
+ }
351
+ }
352
+
353
+ .vote-btn {
354
+ position: relative;
355
+ color: black;
356
+ font-size: 1rem;
357
+ }
358
+
359
+ .vote-btn.selected {
360
+ background-color: var(--primary-color);
361
+ color: white;
362
+ }
363
+
364
+ .vote-btn:disabled {
365
+ opacity: 0.7;
366
+ cursor: not-allowed;
367
+ }
368
+
369
+ .vote-loader {
370
+ position: absolute;
371
+ top: 0;
372
+ left: 0;
373
+ width: 100%;
374
+ height: 100%;
375
+ display: flex;
376
+ align-items: center;
377
+ justify-content: center;
378
+ background-color: rgba(255, 255, 255, 0.8);
379
+ }
380
+
381
+ .vote-spinner {
382
+ width: 20px;
383
+ height: 20px;
384
+ border: 2px solid rgba(80, 70, 229, 0.3);
385
+ border-radius: 50%;
386
+ border-top-color: var(--primary-color);
387
+ animation: spin 1s linear infinite;
388
+ }
389
+
390
+ .next-round-container {
391
+ margin-top: 24px;
392
+ text-align: center;
393
+ }
394
+
395
+ .next-round-btn {
396
+ padding: 12px 24px;
397
+ background-color: var(--primary-color);
398
+ color: white;
399
+ border: none;
400
+ border-radius: var(--radius);
401
+ font-weight: 500;
402
+ cursor: pointer;
403
+ position: relative;
404
+ width: 100%;
405
+ font-size: 1rem;
406
+ transition: background-color 0.2s;
407
+ }
408
+
409
+ .next-round-btn:hover {
410
+ background-color: #4038c7;
411
+ }
412
+
413
+ /* Vote results styling */
414
+ .vote-results {
415
+ background-color: #f0f4ff;
416
+ border: 1px solid #d0d7f7;
417
+ border-radius: var(--radius);
418
+ padding: 16px;
419
+ margin: 24px 0;
420
+ }
421
+
422
+ .results-heading {
423
+ color: var(--primary-color);
424
+ margin-bottom: 12px;
425
+ font-size: 18px;
426
+ }
427
+
428
+ .results-content {
429
+ display: flex;
430
+ flex-direction: column;
431
+ gap: 8px;
432
+ }
433
+
434
+ @keyframes spin {
435
+ to {
436
+ transform: rotate(360deg);
437
+ }
438
+ }
439
+
440
+ /* Tab styling */
441
+ .tabs {
442
+ display: flex;
443
+ border-bottom: 1px solid var(--border-color);
444
+ margin-bottom: 24px;
445
+ }
446
+
447
+ .tab {
448
+ padding: 12px 24px;
449
+ cursor: pointer;
450
+ position: relative;
451
+ font-weight: 500;
452
+ }
453
+
454
+ .tab.active {
455
+ color: var(--primary-color);
456
+ }
457
+
458
+ .tab.active::after {
459
+ content: '';
460
+ position: absolute;
461
+ bottom: -1px;
462
+ left: 0;
463
+ width: 100%;
464
+ height: 2px;
465
+ background-color: var(--primary-color);
466
+ }
467
+
468
+ .tab-content {
469
+ display: none;
470
+ }
471
+
472
+ .tab-content.active {
473
+ display: block;
474
+ }
475
+
476
+ /* Coming soon styling */
477
+ .coming-soon-container {
478
+ display: flex;
479
+ flex-direction: column;
480
+ align-items: center;
481
+ justify-content: center;
482
+ text-align: center;
483
+ padding: 60px 20px;
484
+ background-color: var(--light-gray);
485
+ border-radius: var(--radius);
486
+ margin: 20px 0;
487
+ }
488
+
489
+ .coming-soon-icon {
490
+ color: var(--primary-color);
491
+ margin-bottom: 20px;
492
+ }
493
+
494
+ .coming-soon-title {
495
+ font-size: 24px;
496
+ font-weight: 600;
497
+ margin-bottom: 16px;
498
+ color: var(--text-color);
499
+ }
500
+
501
+ .coming-soon-text {
502
+ font-size: 16px;
503
+ color: #666;
504
+ max-width: 500px;
505
+ line-height: 1.5;
506
+ }
507
+
508
+ .model-name-display {
509
+ font-size: 0.9em;
510
+ color: #666;
511
+ font-style: italic;
512
+ }
513
+
514
+ /* WaveSurfer Custom Styles */
515
+ .player {
516
+ padding-bottom: 20px;
517
+ }
518
+
519
+ .wave-player-container {
520
+ margin-bottom: 16px;
521
+ }
522
+
523
+ /* Keyboard shortcut hint */
524
+ .keyboard-hint {
525
+ text-align: center;
526
+ margin-top: 8px;
527
+ font-size: 13px;
528
+ color: #888;
529
+ }
530
+
531
+ .keyboard-hint kbd {
532
+ display: inline-block;
533
+ padding: 3px 5px;
534
+ font-size: 11px;
535
+ line-height: 10px;
536
+ color: #444;
537
+ vertical-align: middle;
538
+ background-color: #fafafa;
539
+ border: 1px solid #ccc;
540
+ border-radius: 3px;
541
+ box-shadow: 0 1px 0 rgba(0,0,0,0.2);
542
+ margin: 0 2px;
543
+ }
544
+
545
+ @media (max-width: 768px) {
546
+ .input-group {
547
+ border-radius: var(--radius);
548
+ }
549
+
550
+ .synth-btn {
551
+ display: none;
552
+ }
553
+
554
+ .mobile-synth-btn {
555
+ display: block;
556
+ }
557
+
558
+ /* Stack players vertically on mobile */
559
+ .players-row {
560
+ flex-direction: column;
561
+ gap: 16px;
562
+ }
563
+ }
564
+ /* Dark mode styles */
565
+ @media (prefers-color-scheme: dark) {
566
+ .coming-soon-container {
567
+ background-color: var(--light-gray);
568
+ }
569
+
570
+ .coming-soon-text {
571
+ color: #aaa;
572
+ }
573
+
574
+ .model-name-display {
575
+ color: #aaa;
576
+ }
577
+
578
+ /* Fix vote recorded section in dark mode */
579
+ .vote-results {
580
+ background-color: var(--light-gray);
581
+ border-color: var(--border-color);
582
+ }
583
+
584
+ .results-heading {
585
+ color: var(--primary-color);
586
+ }
587
+
588
+ .results-content {
589
+ color: var(--text-color);
590
+ }
591
+
592
+ .chosen-model,
593
+ .rejected-model {
594
+ color: var(--text-color);
595
+ }
596
+
597
+ .chosen-model strong,
598
+ .rejected-model strong {
599
+ color: var(--text-color);
600
+ }
601
+
602
+ .chosen-model-name,
603
+ .rejected-model-name {
604
+ color: var(--text-color);
605
+ }
606
+
607
+ .vote-btn {
608
+ background-color: var(--light-gray);
609
+ color: var(--text-color);
610
+ border-color: var(--border-color);
611
+ }
612
+
613
+ .vote-btn:hover {
614
+ background-color: rgba(255, 255, 255, 0.1);
615
+ border-color: var(--border-color);
616
+ }
617
+
618
+ .vote-btn.selected {
619
+ background-color: var(--primary-color);
620
+ color: white;
621
+ border-color: var(--primary-color);
622
+ }
623
+
624
+ .shortcut-key {
625
+ background-color: rgba(255, 255, 255, 0.1);
626
+ color: var(--text-color);
627
+ border-color: var(--border-color);
628
+ }
629
+
630
+ .vote-btn.selected .shortcut-key {
631
+ background-color: rgba(255, 255, 255, 0.2);
632
+ color: white;
633
+ border-color: transparent;
634
+ }
635
+
636
+ .random-btn {
637
+ background-color: var(--light-gray);
638
+ color: var(--text-color);
639
+ border-color: var(--border-color);
640
+ }
641
+
642
+ .random-btn:hover {
643
+ background-color: rgba(255, 255, 255, 0.1);
644
+ }
645
+
646
+ .vote-recorded {
647
+ background-color: var(--light-gray);
648
+ border-color: var(--border-color);
649
+ }
650
+
651
+ /* Ensure border-radius is maintained during loading state */
652
+ .vote-btn.loading {
653
+ border-radius: var(--radius);
654
+ }
655
+
656
+ /* Dark mode keyboard hint */
657
+ .keyboard-hint {
658
+ color: #aaa;
659
+ }
660
+
661
+ .keyboard-hint kbd {
662
+ color: #ddd;
663
+ background-color: #333;
664
+ border-color: #555;
665
+ box-shadow: 0 1px 0 rgba(255,255,255,0.1);
666
+ }
667
+ }
668
+
669
+ /* Podcast UI styles */
670
+ .podcast-container {
671
+ width: 100%;
672
+ }
673
+
674
+ .podcast-controls {
675
+ display: flex;
676
+ gap: 12px;
677
+ margin-bottom: 24px;
678
+ }
679
+
680
+ .random-script-btn {
681
+ display: flex;
682
+ align-items: center;
683
+ gap: 8px;
684
+ padding: 0 16px;
685
+ height: 40px;
686
+ background-color: white;
687
+ border: 1px solid var(--border-color);
688
+ border-radius: var(--radius);
689
+ cursor: pointer;
690
+ transition: background-color 0.2s;
691
+ }
692
+
693
+ .random-script-btn:hover {
694
+ background-color: var(--light-gray);
695
+ }
696
+
697
+ .podcast-synth-btn {
698
+ padding: 0 24px;
699
+ height: 40px;
700
+ background-color: var(--primary-color);
701
+ color: white;
702
+ border: none;
703
+ border-radius: var(--radius);
704
+ font-weight: 500;
705
+ cursor: pointer;
706
+ transition: background-color 0.2s;
707
+ }
708
+
709
+ .podcast-synth-btn:hover {
710
+ background-color: #4038c7;
711
+ }
712
+
713
+ .podcast-script-container {
714
+ border: 1px solid var(--border-color);
715
+ border-radius: var(--radius);
716
+ overflow: hidden;
717
+ margin-bottom: 24px;
718
+ }
719
+
720
+ .podcast-lines {
721
+ max-height: 500px;
722
+ overflow-y: auto;
723
+ }
724
+
725
+ .podcast-line {
726
+ display: flex;
727
+ border-bottom: 1px solid var(--border-color);
728
+ }
729
+
730
+ .speaker-label {
731
+ width: 120px;
732
+ padding: 12px;
733
+ display: flex;
734
+ align-items: center;
735
+ justify-content: center;
736
+ font-weight: 500;
737
+ border-right: 1px solid var(--border-color);
738
+ background-color: var(--light-gray);
739
+ white-space: nowrap;
740
+ }
741
+
742
+ .speaker-1 {
743
+ color: #3b82f6;
744
+ }
745
+
746
+ .speaker-2 {
747
+ color: #ef4444;
748
+ }
749
+
750
+ .line-input {
751
+ flex: 1;
752
+ padding: 12px;
753
+ border: none;
754
+ outline: none;
755
+ font-size: 1em;
756
+ }
757
+
758
+ .line-input:focus {
759
+ background-color: rgba(80, 70, 229, 0.03);
760
+ }
761
+
762
+ .remove-line-btn {
763
+ width: 40px;
764
+ display: flex;
765
+ align-items: center;
766
+ justify-content: center;
767
+ background: none;
768
+ border: none;
769
+ border-left: 1px solid var(--border-color);
770
+ cursor: pointer;
771
+ color: #888;
772
+ transition: color 0.2s, background-color 0.2s;
773
+ }
774
+
775
+ .remove-line-btn:hover {
776
+ color: #ef4444;
777
+ background-color: rgba(239, 68, 68, 0.1);
778
+ }
779
+
780
+ .add-line-btn {
781
+ width: 100%;
782
+ padding: 12px;
783
+ border: none;
784
+ background-color: var(--light-gray);
785
+ cursor: pointer;
786
+ font-weight: 500;
787
+ transition: background-color 0.2s;
788
+ margin-bottom: 0;
789
+ border-bottom: 1px solid var(--border-color);
790
+ }
791
+
792
+ .add-line-btn:hover {
793
+ background-color: rgba(80, 70, 229, 0.1);
794
+ }
795
+
796
+ .podcast-keyboard-hint {
797
+ padding: 10px;
798
+ text-align: center;
799
+ background-color: var(--light-gray);
800
+ border-top: 1px solid var(--border-color);
801
+ margin-top: 0;
802
+ font-size: 13px;
803
+ }
804
+
805
+ .podcast-player {
806
+ border: 1px solid var(--border-color);
807
+ border-radius: var(--radius);
808
+ padding: 20px;
809
+ margin-bottom: 24px;
810
+ }
811
+
812
+ .podcast-wave-player {
813
+ margin: 20px 0;
814
+ }
815
+
816
+ .podcast-transcript-container {
817
+ margin-top: 20px;
818
+ padding-top: 20px;
819
+ border-top: 1px solid var(--border-color);
820
+ }
821
+
822
+ .podcast-transcript {
823
+ margin-top: 12px;
824
+ line-height: 1.6;
825
+ }
826
+
827
+ .transcript-line {
828
+ margin-bottom: 12px;
829
+ }
830
+
831
+ .transcript-speaker {
832
+ font-weight: 600;
833
+ margin-right: 8px;
834
+ }
835
+
836
+ .transcript-speaker.speaker-1 {
837
+ color: #3b82f6;
838
+ }
839
+
840
+ .transcript-speaker.speaker-2 {
841
+ color: #ef4444;
842
+ }
843
+
844
+ /* Responsive styles for podcast UI */
845
+ @media (max-width: 768px) {
846
+ .podcast-controls {
847
+ flex-direction: column;
848
+ }
849
+
850
+ .random-script-btn,
851
+ .podcast-synth-btn {
852
+ width: 100%;
853
+ height: 48px;
854
+ }
855
+
856
+ /* Stack podcast players vertically on mobile */
857
+ .podcast-player-container .players-row {
858
+ flex-direction: column;
859
+ gap: 16px;
860
+ }
861
+
862
+ .podcast-line {
863
+ flex-direction: column;
864
+ padding-bottom: 0;
865
+ margin-bottom: 0;
866
+ }
867
+
868
+ .speaker-label {
869
+ width: 100%;
870
+ border-right: none;
871
+ border-bottom: 1px solid var(--border-color);
872
+ padding: 8px 10px;
873
+ justify-content: flex-start;
874
+ }
875
+
876
+ .line-input {
877
+ width: 100%;
878
+ padding: 8px 10px;
879
+ }
880
+
881
+ .remove-line-btn {
882
+ position: absolute;
883
+ top: 6px;
884
+ right: 10px;
885
+ border-left: none;
886
+ background-color: rgba(255, 255, 255, 0.5);
887
+ border-radius: 4px;
888
+ width: 30px;
889
+ height: 30px;
890
+ }
891
+
892
+ .podcast-line {
893
+ position: relative;
894
+ }
895
+
896
+ /* Dark mode adjustments for mobile */
897
+ @media (prefers-color-scheme: dark) {
898
+ .remove-line-btn {
899
+ background-color: rgba(50, 50, 60, 0.7);
900
+ }
901
+ }
902
+ }
903
+
904
+ /* Dark mode styles for podcast UI */
905
+ @media (prefers-color-scheme: dark) {
906
+ .random-script-btn {
907
+ background-color: var(--light-gray);
908
+ color: var(--text-color);
909
+ border-color: var(--border-color);
910
+ }
911
+
912
+ .add-line-btn {
913
+ background-color: var(--light-gray);
914
+ color: var(--text-color);
915
+ border-color: var(--border-color);
916
+ }
917
+
918
+ .random-script-btn:hover {
919
+ background-color: rgba(255, 255, 255, 0.1);
920
+ }
921
+
922
+ .line-input {
923
+ background-color: var(--light-gray);
924
+ color: var(--text-color);
925
+ }
926
+
927
+ .line-input:focus {
928
+ background-color: rgba(108, 99, 255, 0.1);
929
+ }
930
+ }
931
+
932
+ .podcast-loading-container {
933
+ display: flex;
934
+ justify-content: center;
935
+ align-items: center;
936
+ position: fixed;
937
+ top: 0;
938
+ left: 0;
939
+ width: 100%;
940
+ height: 100vh;
941
+ background-color: rgba(255, 255, 255, 0.9);
942
+ z-index: 1000;
943
+ }
944
+
945
+ @media (prefers-color-scheme: dark) {
946
+ .podcast-loading-container {
947
+ background-color: rgba(18, 18, 24, 0.9);
948
+ }
949
+ }
950
+
951
+ .podcast-vote-results {
952
+ background-color: #f0f4ff;
953
+ border: 1px solid #d0d7f7;
954
+ border-radius: var(--radius);
955
+ padding: 16px;
956
+ margin: 24px 0;
957
+ }
958
+
959
+ .podcast-next-round-container {
960
+ margin-top: 24px;
961
+ text-align: center;
962
+ }
963
+
964
+ .podcast-next-round-btn {
965
+ padding: 12px 24px;
966
+ background-color: var(--primary-color);
967
+ color: white;
968
+ border: none;
969
+ border-radius: var(--radius);
970
+ font-weight: 500;
971
+ cursor: pointer;
972
+ position: relative;
973
+ width: 100%;
974
+ font-size: 1rem;
975
+ transition: background-color 0.2s;
976
+ }
977
+
978
+ .podcast-next-round-btn:hover {
979
+ background-color: #4038c7;
980
+ }
981
+
982
+ /* Dark mode adjustments */
983
+ @media (prefers-color-scheme: dark) {
984
+ .podcast-vote-results {
985
+ background-color: var(--light-gray);
986
+ border-color: var(--border-color);
987
+ }
988
+ }
989
+ </style>
990
+ {% endblock %}
991
+
992
+ {% block extra_scripts %}
993
+ <script src="{{ url_for('static', filename='js/waveplayer.js') }}"></script>
994
+ <script>
995
+ document.addEventListener('DOMContentLoaded', function() {
996
+ const synthForm = document.querySelector('.input-container');
997
+ const synthBtn = document.querySelector('.synth-btn');
998
+ const mobileSynthBtn = document.querySelector('.mobile-synth-btn');
999
+ const loadingContainer = document.querySelector('.loading-container');
1000
+ const playersContainer = document.querySelector('.players-container');
1001
+ const voteButtons = document.querySelectorAll('.vote-btn');
1002
+ const textInput = document.querySelector('.text-input');
1003
+ const nextRoundBtn = document.querySelector('.next-round-btn');
1004
+ const nextRoundContainer = document.querySelector('.next-round-container');
1005
+ const randomBtn = document.querySelector('.random-btn');
1006
+ const tabs = document.querySelectorAll('.tab');
1007
+ const tabContents = document.querySelectorAll('.tab-content');
1008
+ const voteResultsContainer = document.querySelector('.vote-results');
1009
+ const chosenModelNameElement = document.querySelector('.chosen-model-name');
1010
+ const rejectedModelNameElement = document.querySelector('.rejected-model-name');
1011
+ const modelNameDisplays = document.querySelectorAll('.model-name-display');
1012
+ const wavePlayerContainers = document.querySelectorAll('.wave-player-container');
1013
+
1014
+ let bothSamplesPlayed = false;
1015
+ let currentSessionId = null;
1016
+ let modelNames = { a: '', b: '' };
1017
+ let wavePlayers = { a: null, b: null };
1018
+
1019
+ // Initialize WavePlayers with mobile settings
1020
+ wavePlayerContainers.forEach(container => {
1021
+ const model = container.dataset.model;
1022
+ wavePlayers[model] = new WavePlayer(container, {
1023
+ // Add mobile-friendly options but hide native controls
1024
+ backend: 'MediaElement',
1025
+ mediaControls: false // Hide native audio controls
1026
+ });
1027
+ });
1028
+
1029
+ // Random text options
1030
+ const randomTexts = [
1031
+ "The quick brown fox jumps over the lazy dog.",
1032
+ "To be or not to be, that is the question.",
1033
+ "Life is like a box of chocolates, you never know what you're going to get.",
1034
+ "In a world where technology and humanity intertwine, the voice is our most natural interface.",
1035
+ "Artificial intelligence will transform how we interact with computers and each other.",
1036
+ "The sunset painted the sky with hues of orange and purple.",
1037
+ "I can't believe it's not butter!",
1038
+ "Four score and seven years ago, our forefathers brought forth upon this continent a new nation.",
1039
+ "Houston, we have a problem.",
1040
+ "May the force be with you.",
1041
+ "The early bird catches the worm, but the second mouse gets the cheese.",
1042
+ "All that glitters is not gold; all who wander are not lost.",
1043
+ "Be yourself; everyone else is already taken.",
1044
+ "Two things are infinite: the universe and human stupidity; and I'm not sure about the universe.",
1045
+ "So many books, so little time.",
1046
+ "You only live once, but if you do it right, once is enough.",
1047
+ "In three words I can sum up everything I've learned about life: it goes on.",
1048
+ "The future belongs to those who believe in the beauty of their dreams.",
1049
+ "Yesterday is history, tomorrow is a mystery, but today is a gift. That's why it's called the present.",
1050
+ "The only way to do great work is to love what you do.",
1051
+ "Life is what happens when you're busy making other plans.",
1052
+ "The greatest glory in living lies not in never falling, but in rising every time we fall.",
1053
+ "The way to get started is to quit talking and begin doing.",
1054
+ "If life were predictable it would cease to be life, and be without flavor.",
1055
+ "If you set your goals ridiculously high and it's a failure, you will fail above everyone else's success.",
1056
+ "Life is either a daring adventure or nothing at all.",
1057
+ "Many of life's failures are people who did not realize how close they were to success when they gave up.",
1058
+ "You have brains in your head. You have feet in your shoes. You can steer yourself any direction you choose.",
1059
+ "It is during our darkest moments that we must focus to see the light.",
1060
+ "Don't judge each day by the harvest you reap but by the seeds that you plant.",
1061
+ "The future belongs to those who believe in the beauty of their dreams.",
1062
+ "Tell me and I forget. Teach me and I remember. Involve me and I learn.",
1063
+ "The best and most beautiful things in the world cannot be seen or even touched — they must be felt with the heart.",
1064
+ "It is better to fail in originality than to succeed in imitation.",
1065
+ "Darkness cannot drive out darkness: only light can do that. Hate cannot drive out hate: only love can do that.",
1066
+ "Do not go where the path may lead, go instead where there is no path and leave a trail.",
1067
+ "You will face many defeats in life, but never let yourself be defeated.",
1068
+ "In the end, it's not the years in your life that count. It's the life in your years.",
1069
+ "Never let the fear of striking out keep you from playing the game.",
1070
+ "Life is never fair, and perhaps it is a good thing for most of us that it is not.",
1071
+ ];
1072
+
1073
+ // Check URL hash for direct tab access
1074
+ function checkHashAndSetTab() {
1075
+ const hash = window.location.hash.toLowerCase();
1076
+ if (hash === '#conversational') {
1077
+ // Switch to conversational tab
1078
+ tabs.forEach(t => t.classList.remove('active'));
1079
+ tabContents.forEach(c => c.classList.remove('active'));
1080
+
1081
+ document.querySelector('.tab[data-tab="conversational"]').classList.add('active');
1082
+ document.getElementById('conversational-tab').classList.add('active');
1083
+ } else if (hash === '#tts') {
1084
+ // Switch to TTS tab (explicit)
1085
+ tabs.forEach(t => t.classList.remove('active'));
1086
+ tabContents.forEach(c => c.classList.remove('active'));
1087
+
1088
+ document.querySelector('.tab[data-tab="tts"]').classList.add('active');
1089
+ document.getElementById('tts-tab').classList.add('active');
1090
+ }
1091
+ }
1092
+
1093
+ // Check hash on page load
1094
+ checkHashAndSetTab();
1095
+
1096
+ // Listen for hash changes
1097
+ window.addEventListener('hashchange', checkHashAndSetTab);
1098
+
1099
+ // Tab switching functionality
1100
+ tabs.forEach(tab => {
1101
+ tab.addEventListener('click', function() {
1102
+ const tabId = this.dataset.tab;
1103
+
1104
+ // Update URL hash without page reload
1105
+ history.replaceState(null, null, `#${tabId}`);
1106
+
1107
+ // Remove active class from all tabs and contents
1108
+ tabs.forEach(t => t.classList.remove('active'));
1109
+ tabContents.forEach(c => c.classList.remove('active'));
1110
+
1111
+ // Add active class to clicked tab and corresponding content
1112
+ this.classList.add('active');
1113
+ document.getElementById(`${tabId}-tab`).classList.add('active');
1114
+
1115
+ // Reset TTS tab state if switching away from it
1116
+ if (tabId !== 'tts') {
1117
+ resetToInitialState();
1118
+ }
1119
+ });
1120
+ });
1121
+
1122
+ function handleSynthesize(e) {
1123
+ if (e) {
1124
+ e.preventDefault();
1125
+ }
1126
+
1127
+ const text = textInput.value.trim();
1128
+ if (!text) {
1129
+ openToast("Please enter some text to synthesize", "warning");
1130
+ return;
1131
+ }
1132
+
1133
+ if (text.length > 1000) {
1134
+ openToast("Text is too long. Please keep it under 1000 characters.", "warning");
1135
+ return;
1136
+ }
1137
+
1138
+ textInput.blur();
1139
+
1140
+ // Show loading animation
1141
+ loadingContainer.style.display = 'flex';
1142
+ playersContainer.style.display = 'none';
1143
+ voteResultsContainer.style.display = 'none';
1144
+ nextRoundContainer.style.display = 'none';
1145
+
1146
+ // Reset vote buttons
1147
+ voteButtons.forEach(btn => {
1148
+ btn.disabled = true;
1149
+ btn.classList.remove('selected');
1150
+ btn.querySelector('.vote-loader').style.display = 'none';
1151
+ });
1152
+
1153
+ // Clear model name displays
1154
+ modelNameDisplays.forEach(display => {
1155
+ display.textContent = '';
1156
+ });
1157
+
1158
+ // Reset the flag for both samples played
1159
+ bothSamplesPlayed = false;
1160
+
1161
+ // Call the API to generate TTS
1162
+ fetch('/api/tts/generate', {
1163
+ method: 'POST',
1164
+ headers: {
1165
+ 'Content-Type': 'application/json',
1166
+ },
1167
+ body: JSON.stringify({ text: text }),
1168
+ })
1169
+ .then(response => {
1170
+ if (!response.ok) {
1171
+ return response.json().then(err => {
1172
+ throw new Error(err.error || 'Failed to generate TTS');
1173
+ });
1174
+ }
1175
+ return response.json();
1176
+ })
1177
+ .then(data => {
1178
+ currentSessionId = data.session_id;
1179
+
1180
+ // Load audio in waveplayers
1181
+ wavePlayers.a.loadAudio(data.audio_a);
1182
+ wavePlayers.b.loadAudio(data.audio_b);
1183
+
1184
+ // Show players
1185
+ loadingContainer.style.display = 'none';
1186
+ playersContainer.style.display = 'flex';
1187
+
1188
+ // Setup automatic sequential playback
1189
+ wavePlayers.a.wavesurfer.once('ready', function() {
1190
+ wavePlayers.a.play();
1191
+
1192
+ // When audio A ends, play audio B
1193
+ wavePlayers.a.wavesurfer.once('finish', function() {
1194
+ // Wait a short moment before playing B
1195
+ setTimeout(() => {
1196
+ wavePlayers.b.play();
1197
+
1198
+ // When audio B ends, enable voting
1199
+ wavePlayers.b.wavesurfer.once('finish', function() {
1200
+ bothSamplesPlayed = true;
1201
+ voteButtons.forEach(btn => {
1202
+ btn.disabled = false;
1203
+ });
1204
+ });
1205
+ }, 500);
1206
+ });
1207
+ });
1208
+ })
1209
+ .catch(error => {
1210
+ loadingContainer.style.display = 'none';
1211
+ openToast(error.message, "error");
1212
+ console.error('Error:', error);
1213
+ });
1214
+ }
1215
+
1216
+ function handleVote(model) {
1217
+ // Disable both vote buttons
1218
+ voteButtons.forEach(btn => {
1219
+ btn.disabled = true;
1220
+ if (btn.dataset.model === model) {
1221
+ btn.querySelector('.vote-loader').style.display = 'flex';
1222
+ }
1223
+ });
1224
+
1225
+ // Send vote to server
1226
+ fetch('/api/tts/vote', {
1227
+ method: 'POST',
1228
+ headers: {
1229
+ 'Content-Type': 'application/json',
1230
+ },
1231
+ body: JSON.stringify({
1232
+ session_id: currentSessionId,
1233
+ chosen_model: model
1234
+ }),
1235
+ })
1236
+ .then(response => {
1237
+ if (!response.ok) {
1238
+ return response.json().then(err => {
1239
+ throw new Error(err.error || 'Failed to submit vote');
1240
+ });
1241
+ }
1242
+ return response.json();
1243
+ })
1244
+ .then(data => {
1245
+ // Hide loaders
1246
+ voteButtons.forEach(btn => {
1247
+ btn.querySelector('.vote-loader').style.display = 'none';
1248
+
1249
+ // Highlight the selected button
1250
+ if (btn.dataset.model === model) {
1251
+ btn.classList.add('selected');
1252
+ }
1253
+ });
1254
+
1255
+
1256
+ // Store model names from vote response
1257
+ if (data.chosen_model && data.chosen_model.name) {
1258
+ modelNames.a = data.names.a;
1259
+ modelNames.b = data.names.b;
1260
+ }
1261
+
1262
+ // Now display model names after voting
1263
+ modelNameDisplays[0].textContent = modelNames.a ? `(${modelNames.a})` : '';
1264
+ modelNameDisplays[1].textContent = modelNames.b ? `(${modelNames.b})` : '';
1265
+
1266
+ // Show vote results
1267
+ chosenModelNameElement.textContent = data.chosen_model.name;
1268
+ rejectedModelNameElement.textContent = data.rejected_model.name;
1269
+ voteResultsContainer.style.display = 'block';
1270
+
1271
+ // Show next round button
1272
+ nextRoundContainer.style.display = 'block';
1273
+
1274
+ // Show success toast
1275
+ openToast("Vote recorded successfully!", "success");
1276
+ })
1277
+ .catch(error => {
1278
+ // Re-enable vote buttons
1279
+ voteButtons.forEach(btn => {
1280
+ btn.disabled = false;
1281
+ btn.querySelector('.vote-loader').style.display = 'none';
1282
+ });
1283
+
1284
+ openToast(error.message, "error");
1285
+ console.error('Error:', error);
1286
+ });
1287
+ }
1288
+
1289
+ function resetToInitialState() {
1290
+ // Hide players, results, and next round button
1291
+ playersContainer.style.display = 'none';
1292
+ voteResultsContainer.style.display = 'none';
1293
+ nextRoundContainer.style.display = 'none';
1294
+
1295
+ // Reset vote buttons
1296
+ voteButtons.forEach(btn => {
1297
+ btn.disabled = true;
1298
+ btn.classList.remove('selected');
1299
+ btn.querySelector('.vote-loader').style.display = 'none';
1300
+ });
1301
+
1302
+ // Clear model name displays
1303
+ modelNameDisplays.forEach(display => {
1304
+ display.textContent = '';
1305
+ });
1306
+
1307
+ // Reset model names
1308
+ modelNames = { a: '', b: '' };
1309
+
1310
+ // Clear text input
1311
+ textInput.value = '';
1312
+
1313
+ // Stop any playing audio and destroy wavesurfers
1314
+ for (const model in wavePlayers) {
1315
+ if (wavePlayers[model]) {
1316
+ wavePlayers[model].stop();
1317
+ }
1318
+ }
1319
+
1320
+ // Reset session
1321
+ currentSessionId = null;
1322
+
1323
+ // Reset the flag for both samples played
1324
+ bothSamplesPlayed = false;
1325
+ }
1326
+
1327
+ function handleRandom() {
1328
+ // Select a random text from the array
1329
+ const randomText = randomTexts[Math.floor(Math.random() * randomTexts.length)];
1330
+ textInput.value = randomText;
1331
+ textInput.focus();
1332
+ }
1333
+
1334
+ function showListenToastMessage() {
1335
+ openToast("Please listen to both audio samples before voting", "info");
1336
+ }
1337
+
1338
+ // Add submit event listener to form
1339
+ synthForm.addEventListener('submit', handleSynthesize);
1340
+
1341
+ // Add click event listeners to vote buttons
1342
+ voteButtons.forEach(btn => {
1343
+ btn.addEventListener('click', function() {
1344
+ if (bothSamplesPlayed) {
1345
+ const model = this.dataset.model;
1346
+ handleVote(model);
1347
+ } else {
1348
+ showListenToastMessage();
1349
+ }
1350
+ });
1351
+ });
1352
+
1353
+ // Add keyboard shortcut listeners
1354
+ document.addEventListener('keydown', function(e) {
1355
+ // Check if TTS tab is active
1356
+ const ttsTab = document.getElementById('tts-tab');
1357
+ if (!ttsTab.classList.contains('active')) return;
1358
+
1359
+ // Only process keyboard shortcuts if text input is not focused
1360
+ if (document.activeElement === textInput) {
1361
+ return;
1362
+ }
1363
+
1364
+ if (e.key.toLowerCase() === 'a') {
1365
+ if (bothSamplesPlayed && !voteButtons[0].disabled) {
1366
+ handleVote('a');
1367
+ } else if (playersContainer.style.display !== 'none' && !bothSamplesPlayed) {
1368
+ showListenToastMessage();
1369
+ }
1370
+ } else if (e.key.toLowerCase() === 'b') {
1371
+ if (bothSamplesPlayed && !voteButtons[1].disabled) {
1372
+ handleVote('b');
1373
+ } else if (playersContainer.style.display !== 'none' && !bothSamplesPlayed) {
1374
+ showListenToastMessage();
1375
+ }
1376
+ } else if (e.key.toLowerCase() === 'n') {
1377
+ if (nextRoundContainer.style.display === 'block') {
1378
+ if (!e.ctrlKey && !e.metaKey) {
1379
+ e.preventDefault();
1380
+ }
1381
+ resetToInitialState();
1382
+ }
1383
+ } else if (e.key.toLowerCase() === 'r') {
1384
+ // Only trigger random if not trying to reload (Ctrl+R or Cmd+R)
1385
+ if (!e.ctrlKey && !e.metaKey) {
1386
+ e.preventDefault();
1387
+ handleRandom();
1388
+ }
1389
+ } else if (e.key === ' ') {
1390
+ // Space to play/pause current audio
1391
+ if (playersContainer.style.display !== 'none') {
1392
+ e.preventDefault();
1393
+ // If A is playing, toggle A, else if B is playing, toggle B, else play A
1394
+ if (wavePlayers.a.isPlaying) {
1395
+ wavePlayers.a.togglePlayPause();
1396
+ } else if (wavePlayers.b.isPlaying) {
1397
+ wavePlayers.b.togglePlayPause();
1398
+ } else {
1399
+ wavePlayers.a.play();
1400
+ }
1401
+ }
1402
+ }
1403
+ });
1404
+
1405
+ // Add event listener for random button
1406
+ randomBtn.addEventListener('click', handleRandom);
1407
+
1408
+ // Add event listener for next round button
1409
+ nextRoundBtn.addEventListener('click', resetToInitialState);
1410
+ });
1411
+ </script>
1412
+
1413
+ <script>
1414
+ document.addEventListener('DOMContentLoaded', function() {
1415
+ // Variables for podcast UI
1416
+ const podcastContainer = document.querySelector('.podcast-container');
1417
+ const podcastLinesContainer = document.querySelector('.podcast-lines');
1418
+ const addLineBtn = document.querySelector('.add-line-btn');
1419
+ const randomScriptBtn = document.querySelector('.random-script-btn');
1420
+ const podcastSynthBtn = document.querySelector('.podcast-synth-btn');
1421
+ const podcastLoadingContainer = document.querySelector('.podcast-loading-container');
1422
+ const podcastPlayerContainer = document.querySelector('.podcast-player-container');
1423
+ const podcastWavePlayerA = document.querySelector('.podcast-wave-player-a');
1424
+ const podcastWavePlayerB = document.querySelector('.podcast-wave-player-b');
1425
+ const podcastVoteButtons = podcastPlayerContainer.querySelectorAll('.vote-btn');
1426
+ const podcastVoteResults = podcastPlayerContainer.querySelector('.vote-results');
1427
+ const podcastNextRoundContainer = podcastPlayerContainer.querySelector('.next-round-container');
1428
+ const podcastNextRoundBtn = podcastPlayerContainer.querySelector('.next-round-btn');
1429
+ const chosenModelNameElement = podcastVoteResults.querySelector('.chosen-model-name');
1430
+ const rejectedModelNameElement = podcastVoteResults.querySelector('.rejected-model-name');
1431
+
1432
+ let podcastWavePlayers = { a: null, b: null };
1433
+ let bothPodcastSamplesPlayed = false;
1434
+ let currentPodcastSessionId = null;
1435
+ let podcastModelNames = { a: 'Model A', b: 'Model B' };
1436
+
1437
+ // Sample random scripts for the podcast
1438
+ const randomScripts = [
1439
+ [
1440
+ { speaker: 1, text: "Welcome to our podcast about artificial intelligence. Today we're discussing the latest advances in text-to-speech technology." },
1441
+ { speaker: 2, text: "That's right! Text-to-speech has come a long way in recent years. The voices sound increasingly natural." },
1442
+ { speaker: 1, text: "What do you think are the most impressive recent developments?" },
1443
+ { speaker: 2, text: "I'd say the emotion and inflection that modern TTS systems can convey is truly remarkable." }
1444
+ ],
1445
+ [
1446
+ { speaker: 1, text: "So today we're talking about climate change and its effects on our planet." },
1447
+ { speaker: 2, text: "It's such an important topic. We're seeing more extreme weather events every year." },
1448
+ { speaker: 1, text: "Absolutely. And the science is clear that human activity is the primary driver." },
1449
+ { speaker: 2, text: "What can individuals do to help address this global challenge?" }
1450
+ ],
1451
+ [
1452
+ { speaker: 1, text: "In today's episode, we're exploring the world of modern cinema." },
1453
+ { speaker: 2, text: "Film has evolved so much since its early days. What's your favorite era of movies?" },
1454
+ { speaker: 1, text: "I'm particularly fond of the 1970s New Hollywood movement. Films like The Godfather and Taxi Driver really pushed boundaries." },
1455
+ { speaker: 2, text: "Interesting choice! I'm more drawn to contemporary international cinema, especially from directors like Bong Joon-ho and Park Chan-wook." }
1456
+ ],
1457
+ [
1458
+ { speaker: 1, text: "Today we're discussing the future of remote work. How do you think it's changed the workplace?" },
1459
+ { speaker: 2, text: "I believe it's revolutionized how we think about productivity and work-life balance." },
1460
+ { speaker: 1, text: "Do you think companies will continue to offer remote options post-pandemic?" },
1461
+ { speaker: 2, text: "Absolutely. Companies that don't embrace flexibility will struggle to attract top talent." }
1462
+ ],
1463
+ [
1464
+ { speaker: 1, text: "Let's talk about the latest developments in renewable energy." },
1465
+ { speaker: 2, text: "Solar and wind have become increasingly cost-effective in recent years." },
1466
+ { speaker: 1, text: "What about emerging technologies like green hydrogen?" },
1467
+ { speaker: 2, text: "That's a fascinating area with huge potential, especially for industries that are difficult to electrify." }
1468
+ ],
1469
+ [
1470
+ { speaker: 1, text: "The world of cryptocurrency has seen massive changes lately. What's your take?" },
1471
+ { speaker: 2, text: "It's certainly volatile, but I think blockchain technology has applications beyond just digital currency." },
1472
+ { speaker: 1, text: "Do you see it becoming mainstream in the financial sector?" },
1473
+ { speaker: 2, text: "Parts of it already are. Central banks are exploring digital currencies, and major companies are investing in blockchain." }
1474
+ ],
1475
+ [
1476
+ { speaker: 1, text: "Mental health awareness has grown significantly in recent years." },
1477
+ { speaker: 2, text: "Yes, and it's about time. The stigma around seeking help is finally starting to diminish." },
1478
+ { speaker: 1, text: "What do you think has driven this change?" },
1479
+ { speaker: 2, text: "I think social media has played a role, with more people openly sharing their experiences." }
1480
+ ],
1481
+ [
1482
+ { speaker: 1, text: "Space exploration is entering an exciting new era with private companies leading the charge." },
1483
+ { speaker: 2, text: "The commercialization of space has definitely accelerated innovation in the field." },
1484
+ { speaker: 1, text: "Do you think we'll see humans on Mars in our lifetime?" },
1485
+ { speaker: 2, text: "I'm optimistic. The technology is advancing rapidly, and there's strong motivation from both public and private sectors." }
1486
+ ],
1487
+ [
1488
+ { speaker: 1, text: "Today's topic is sustainable fashion. How can consumers make more ethical choices?" },
1489
+ { speaker: 2, text: "It starts with buying less and choosing quality items that last longer." },
1490
+ { speaker: 1, text: "What about the responsibility of fashion brands themselves?" },
1491
+ { speaker: 2, text: "They need to be transparent about their supply chains and commit to reducing their environmental impact." }
1492
+ ],
1493
+ [
1494
+ { speaker: 1, text: "Let's discuss the evolution of social media and its impact on society." },
1495
+ { speaker: 2, text: "It's transformed how we connect, but also created new challenges like misinformation and privacy concerns." },
1496
+ { speaker: 1, text: "Do you think regulation is the answer?" },
1497
+ { speaker: 2, text: "Partly, but digital literacy education is equally important so people can navigate these platforms responsibly." }
1498
+ ],
1499
+ [
1500
+ { speaker: 1, text: "The field of genomics has seen remarkable progress. What excites you most about it?" },
1501
+ { speaker: 2, text: "Personalized medicine is fascinating - the idea that treatments can be tailored to an individual's genetic makeup." },
1502
+ { speaker: 1, text: "What about the ethical considerations?" },
1503
+ { speaker: 2, text: "Those are crucial. We need robust frameworks to ensure these technologies are used responsibly." }
1504
+ ],
1505
+ [
1506
+ { speaker: 1, text: "Urban planning is facing new challenges in the 21st century. What trends are you seeing?" },
1507
+ { speaker: 2, text: "There's a growing focus on creating walkable, mixed-use neighborhoods that reduce car dependency." },
1508
+ { speaker: 1, text: "How are cities adapting to climate change?" },
1509
+ { speaker: 2, text: "Many are implementing green infrastructure like parks and permeable surfaces to manage flooding and reduce heat islands." }
1510
+ ],
1511
+ [
1512
+ { speaker: 1, text: "The gaming industry has grown enormously in recent years. What's driving this expansion?" },
1513
+ { speaker: 2, text: "Gaming has become much more accessible across different platforms, and the pandemic certainly accelerated adoption." },
1514
+ { speaker: 1, text: "What do you think about the rise of esports?" },
1515
+ { speaker: 2, text: "It's fascinating to see competitive gaming achieve mainstream recognition and create new career opportunities." }
1516
+ ],
1517
+ [
1518
+ { speaker: 1, text: "Let's talk about the future of transportation. How will we get around in 20 years?" },
1519
+ { speaker: 2, text: "Electric vehicles will be dominant, and autonomous driving technology will be much more widespread." },
1520
+ { speaker: 1, text: "What about public transit and alternative modes?" },
1521
+ { speaker: 2, text: "I think we'll see more integrated systems where bikes, scooters, and public transit work seamlessly together." }
1522
+ ]
1523
+ ];
1524
+
1525
+ // Initialize with 2 empty lines
1526
+ function initializePodcastLines() {
1527
+ podcastLinesContainer.innerHTML = '';
1528
+ addPodcastLine(1);
1529
+ addPodcastLine(2);
1530
+ }
1531
+
1532
+ // Add a new podcast line
1533
+ function addPodcastLine(speakerNum = null) {
1534
+ const lineCount = podcastLinesContainer.querySelectorAll('.podcast-line').length;
1535
+
1536
+ // If speaker number isn't specified, alternate between 1 and 2
1537
+ if (speakerNum === null) {
1538
+ speakerNum = (lineCount % 2) + 1;
1539
+ }
1540
+
1541
+ const lineElement = document.createElement('div');
1542
+ lineElement.className = 'podcast-line';
1543
+
1544
+ lineElement.innerHTML = `
1545
+ <div class="speaker-label speaker-${speakerNum}">Speaker ${speakerNum}</div>
1546
+ <input type="text" class="line-input" placeholder="Enter dialog...">
1547
+ <button type="button" class="remove-line-btn" tabindex="-1">
1548
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
1549
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1550
+ <line x1="18" y1="6" x2="6" y2="18"></line>
1551
+ <line x1="6" y1="6" x2="18" y2="18"></line>
1552
+ </svg>
1553
+ </button>
1554
+ `;
1555
+
1556
+ podcastLinesContainer.appendChild(lineElement);
1557
+
1558
+ // Add event listener to remove button
1559
+ const removeBtn = lineElement.querySelector('.remove-line-btn');
1560
+ removeBtn.addEventListener('click', function() {
1561
+ // Don't allow removing if there are only 2 lines
1562
+ if (podcastLinesContainer.querySelectorAll('.podcast-line').length > 2) {
1563
+ lineElement.remove();
1564
+ } else {
1565
+ openToast("At least 2 lines are required", "warning");
1566
+ }
1567
+ });
1568
+
1569
+ // Add event listener for keyboard navigation in the input field
1570
+ const inputField = lineElement.querySelector('.line-input');
1571
+ inputField.addEventListener('keydown', function(e) {
1572
+ // Alt+Enter or Ctrl+Enter to add new line
1573
+ if (e.key === 'Enter' && (e.altKey || e.ctrlKey)) {
1574
+ e.preventDefault();
1575
+ addPodcastLine();
1576
+
1577
+ // Focus the new line's input field
1578
+ setTimeout(() => {
1579
+ const inputs = podcastLinesContainer.querySelectorAll('.line-input');
1580
+ inputs[inputs.length - 1].focus();
1581
+ }, 10);
1582
+ }
1583
+ });
1584
+
1585
+ return lineElement;
1586
+ }
1587
+
1588
+ // Load a random script
1589
+ function loadRandomScript() {
1590
+ // Clear existing lines
1591
+ podcastLinesContainer.innerHTML = '';
1592
+
1593
+ // Select a random script
1594
+ const randomScript = randomScripts[Math.floor(Math.random() * randomScripts.length)];
1595
+
1596
+ // Add each line from the script
1597
+ randomScript.forEach(line => {
1598
+ const lineElement = addPodcastLine(line.speaker);
1599
+ lineElement.querySelector('.line-input').value = line.text;
1600
+ });
1601
+ }
1602
+
1603
+ // Generate podcast (mock functionality)
1604
+ function generatePodcast() {
1605
+ // Get all lines
1606
+ const lines = [];
1607
+ podcastLinesContainer.querySelectorAll('.podcast-line').forEach(line => {
1608
+ const speaker_id = line.querySelector('.speaker-label').textContent.includes('1') ? 0 : 1;
1609
+ const text = line.querySelector('.line-input').value.trim();
1610
+
1611
+ if (text) {
1612
+ lines.push({ speaker_id, text });
1613
+ }
1614
+ });
1615
+
1616
+ // Validate that we have at least 2 lines with content
1617
+ if (lines.length < 2) {
1618
+ openToast("Please enter at least 2 lines of dialog", "warning");
1619
+ return;
1620
+ }
1621
+
1622
+ // Reset vote buttons and hide results
1623
+ podcastVoteButtons.forEach(btn => {
1624
+ btn.disabled = true;
1625
+ btn.classList.remove('selected');
1626
+ btn.querySelector('.vote-loader').style.display = 'none';
1627
+ });
1628
+
1629
+ // Clear model name displays
1630
+ const modelNameDisplays = podcastPlayerContainer.querySelectorAll('.model-name-display');
1631
+ modelNameDisplays.forEach(display => {
1632
+ display.textContent = '';
1633
+ });
1634
+
1635
+ podcastVoteResults.style.display = 'none';
1636
+ podcastNextRoundContainer.style.display = 'none';
1637
+
1638
+ // Reset the flag for both samples played
1639
+ bothPodcastSamplesPlayed = false;
1640
+
1641
+ // Show loading animation
1642
+ podcastLoadingContainer.style.display = 'flex';
1643
+ podcastPlayerContainer.style.display = 'none';
1644
+
1645
+ // Call API to generate podcast
1646
+ fetch('/api/conversational/generate', {
1647
+ method: 'POST',
1648
+ headers: {
1649
+ 'Content-Type': 'application/json',
1650
+ },
1651
+ body: JSON.stringify({ script: lines }),
1652
+ })
1653
+ .then(response => {
1654
+ if (!response.ok) {
1655
+ return response.json().then(err => {
1656
+ throw new Error(err.error || 'Failed to generate podcast');
1657
+ });
1658
+ }
1659
+ return response.json();
1660
+ })
1661
+ .then(data => {
1662
+ currentPodcastSessionId = data.session_id;
1663
+
1664
+ // Hide loading
1665
+ podcastLoadingContainer.style.display = 'none';
1666
+
1667
+ // Show player
1668
+ podcastPlayerContainer.style.display = 'block';
1669
+
1670
+ // Initialize WavePlayers if not already done
1671
+ if (!podcastWavePlayers.a) {
1672
+ podcastWavePlayers.a = new WavePlayer(podcastWavePlayerA, {
1673
+ // Add mobile-friendly options but hide native controls
1674
+ backend: 'MediaElement',
1675
+ mediaControls: false // Hide native audio controls
1676
+ });
1677
+ podcastWavePlayers.b = new WavePlayer(podcastWavePlayerB, {
1678
+ // Add mobile-friendly options but hide native controls
1679
+ backend: 'MediaElement',
1680
+ mediaControls: false // Hide native audio controls
1681
+ });
1682
+
1683
+ // Load audio in waveplayers
1684
+ podcastWavePlayers.a.loadAudio(data.audio_a);
1685
+ podcastWavePlayers.b.loadAudio(data.audio_b);
1686
+
1687
+ // Force hide loading indicators after 5 seconds as a fallback
1688
+ setTimeout(() => {
1689
+ if (podcastWavePlayers.a && podcastWavePlayers.a.hideLoading) {
1690
+ podcastWavePlayers.a.hideLoading();
1691
+ }
1692
+ if (podcastWavePlayers.b && podcastWavePlayers.b.hideLoading) {
1693
+ podcastWavePlayers.b.hideLoading();
1694
+ }
1695
+ console.log('Forced hiding of podcast loading indicators (safety timeout - existing players)');
1696
+ }, 5000);
1697
+ } else {
1698
+ // Reset and reload for existing players
1699
+ try {
1700
+ podcastWavePlayers.a.wavesurfer.empty();
1701
+ podcastWavePlayers.b.wavesurfer.empty();
1702
+
1703
+ // Make sure loading indicators are reset
1704
+ podcastWavePlayers.a.hideLoading();
1705
+ podcastWavePlayers.b.hideLoading();
1706
+
1707
+ podcastWavePlayers.a.loadAudio(data.audio_a);
1708
+ podcastWavePlayers.b.loadAudio(data.audio_b);
1709
+
1710
+ // Force hide loading indicators after 5 seconds as a fallback
1711
+ setTimeout(() => {
1712
+ if (podcastWavePlayers.a && podcastWavePlayers.a.hideLoading) {
1713
+ podcastWavePlayers.a.hideLoading();
1714
+ }
1715
+ if (podcastWavePlayers.b && podcastWavePlayers.b.hideLoading) {
1716
+ podcastWavePlayers.b.hideLoading();
1717
+ }
1718
+ console.log('Forced hiding of podcast loading indicators (safety timeout - existing players)');
1719
+ }, 5000);
1720
+ } catch (err) {
1721
+ console.error('Error resetting podcast waveplayers:', err);
1722
+
1723
+ // Recreate the players if there was an error
1724
+ podcastWavePlayers.a = new WavePlayer(podcastWavePlayerA, {
1725
+ backend: 'MediaElement',
1726
+ mediaControls: false
1727
+ });
1728
+ podcastWavePlayers.b = new WavePlayer(podcastWavePlayerB, {
1729
+ backend: 'MediaElement',
1730
+ mediaControls: false
1731
+ });
1732
+
1733
+ podcastWavePlayers.a.loadAudio(data.audio_a);
1734
+ podcastWavePlayers.b.loadAudio(data.audio_b);
1735
+
1736
+ // Force hide loading indicators after 5 seconds as a fallback
1737
+ setTimeout(() => {
1738
+ if (podcastWavePlayers.a && podcastWavePlayers.a.hideLoading) {
1739
+ podcastWavePlayers.a.hideLoading();
1740
+ }
1741
+ if (podcastWavePlayers.b && podcastWavePlayers.b.hideLoading) {
1742
+ podcastWavePlayers.b.hideLoading();
1743
+ }
1744
+ console.log('Forced hiding of podcast loading indicators (fallback case)');
1745
+ }, 5000);
1746
+ }
1747
+ }
1748
+
1749
+ // Setup automatic sequential playback
1750
+ podcastWavePlayers.a.wavesurfer.once('ready', function() {
1751
+ podcastWavePlayers.a.play();
1752
+
1753
+ // When audio A ends, play audio B
1754
+ podcastWavePlayers.a.wavesurfer.once('finish', function() {
1755
+ // Wait a short moment before playing B
1756
+ setTimeout(() => {
1757
+ podcastWavePlayers.b.play();
1758
+
1759
+ // When audio B ends, enable voting
1760
+ podcastWavePlayers.b.wavesurfer.once('finish', function() {
1761
+ bothPodcastSamplesPlayed = true;
1762
+ podcastVoteButtons.forEach(btn => {
1763
+ btn.disabled = false;
1764
+ });
1765
+ });
1766
+ }, 500);
1767
+ });
1768
+ });
1769
+ })
1770
+ .catch(error => {
1771
+ podcastLoadingContainer.style.display = 'none';
1772
+ openToast(error.message, "error");
1773
+ console.error('Error:', error);
1774
+ });
1775
+ }
1776
+
1777
+ // Handle vote for a podcast model
1778
+ function handlePodcastVote(model) {
1779
+ // Disable both vote buttons
1780
+ podcastVoteButtons.forEach(btn => {
1781
+ btn.disabled = true;
1782
+ if (btn.dataset.model === model) {
1783
+ btn.querySelector('.vote-loader').style.display = 'flex';
1784
+ }
1785
+ });
1786
+
1787
+ // Send vote to server
1788
+ fetch('/api/conversational/vote', {
1789
+ method: 'POST',
1790
+ headers: {
1791
+ 'Content-Type': 'application/json',
1792
+ },
1793
+ body: JSON.stringify({
1794
+ session_id: currentPodcastSessionId,
1795
+ chosen_model: model
1796
+ }),
1797
+ })
1798
+ .then(response => {
1799
+ if (!response.ok) {
1800
+ return response.json().then(err => {
1801
+ throw new Error(err.error || 'Failed to submit vote');
1802
+ });
1803
+ }
1804
+ return response.json();
1805
+ })
1806
+ .then(data => {
1807
+ // Hide loaders
1808
+ podcastVoteButtons.forEach(btn => {
1809
+ btn.querySelector('.vote-loader').style.display = 'none';
1810
+
1811
+ // Highlight the selected button
1812
+ if (btn.dataset.model === model) {
1813
+ btn.classList.add('selected');
1814
+ }
1815
+ });
1816
+
1817
+ // Store model names from vote response
1818
+ podcastModelNames.a = data.names.a;
1819
+ podcastModelNames.b = data.names.b;
1820
+
1821
+ // Show model names after voting
1822
+ const modelNameDisplays = podcastPlayerContainer.querySelectorAll('.model-name-display');
1823
+ modelNameDisplays[0].textContent = data.names.a ? `(${data.names.a})` : '';
1824
+ modelNameDisplays[1].textContent = data.names.b ? `(${data.names.b})` : '';
1825
+
1826
+ // Show vote results
1827
+ chosenModelNameElement.textContent = data.chosen_model.name;
1828
+ rejectedModelNameElement.textContent = data.rejected_model.name;
1829
+ podcastVoteResults.style.display = 'block';
1830
+
1831
+ // Show next round button
1832
+ podcastNextRoundContainer.style.display = 'block';
1833
+
1834
+ // Show success toast
1835
+ openToast("Vote recorded successfully!", "success");
1836
+ })
1837
+ .catch(error => {
1838
+ // Re-enable vote buttons
1839
+ podcastVoteButtons.forEach(btn => {
1840
+ btn.disabled = false;
1841
+ btn.querySelector('.vote-loader').style.display = 'none';
1842
+ });
1843
+
1844
+ openToast(error.message, "error");
1845
+ console.error('Error:', error);
1846
+ });
1847
+ }
1848
+
1849
+ // Reset podcast UI to initial state
1850
+ function resetPodcastState() {
1851
+ // Hide players, results, and next round button
1852
+ podcastPlayerContainer.style.display = 'none';
1853
+ podcastVoteResults.style.display = 'none';
1854
+ podcastNextRoundContainer.style.display = 'none';
1855
+
1856
+ // Reset vote buttons
1857
+ podcastVoteButtons.forEach(btn => {
1858
+ btn.disabled = true;
1859
+ btn.classList.remove('selected');
1860
+ btn.querySelector('.vote-loader').style.display = 'none';
1861
+ });
1862
+
1863
+ // Clear model name displays
1864
+ const modelNameDisplays = podcastPlayerContainer.querySelectorAll('.model-name-display');
1865
+ modelNameDisplays.forEach(display => {
1866
+ display.textContent = '';
1867
+ });
1868
+
1869
+ // Stop any playing audio
1870
+ if (podcastWavePlayers.a) podcastWavePlayers.a.stop();
1871
+ if (podcastWavePlayers.b) podcastWavePlayers.b.stop();
1872
+
1873
+ // Reset session
1874
+ currentPodcastSessionId = null;
1875
+
1876
+ // Reset the flag for both samples played
1877
+ bothPodcastSamplesPlayed = false;
1878
+ }
1879
+
1880
+ // Add keyboard shortcut listeners for podcast voting
1881
+ document.addEventListener('keydown', function(e) {
1882
+ // Check if we're in the podcast tab and it's active
1883
+ const podcastTab = document.getElementById('conversational-tab');
1884
+ if (!podcastTab.classList.contains('active')) return;
1885
+
1886
+ // Only process if input fields are not focused
1887
+ if (document.activeElement.tagName === 'INPUT' ||
1888
+ document.activeElement.tagName === 'TEXTAREA') {
1889
+ return;
1890
+ }
1891
+
1892
+ if (e.key.toLowerCase() === 'a') {
1893
+ if (bothPodcastSamplesPlayed && !podcastVoteButtons[0].disabled) {
1894
+ handlePodcastVote('a');
1895
+ } else if (podcastPlayerContainer.style.display !== 'none' && !bothPodcastSamplesPlayed) {
1896
+ openToast("Please listen to both audio samples before voting", "info");
1897
+ }
1898
+ } else if (e.key.toLowerCase() === 'b') {
1899
+ if (bothPodcastSamplesPlayed && !podcastVoteButtons[1].disabled) {
1900
+ handlePodcastVote('b');
1901
+ } else if (podcastPlayerContainer.style.display !== 'none' && !bothPodcastSamplesPlayed) {
1902
+ openToast("Please listen to both audio samples before voting", "info");
1903
+ }
1904
+ } else if (e.key.toLowerCase() === 'n') {
1905
+ if (podcastNextRoundContainer.style.display === 'block') {
1906
+ if (!e.ctrlKey && !e.metaKey) {
1907
+ e.preventDefault();
1908
+ }
1909
+ resetPodcastState();
1910
+ }
1911
+ } else if (e.key === ' ') {
1912
+ // Space to play/pause current audio
1913
+ if (podcastPlayerContainer.style.display !== 'none') {
1914
+ e.preventDefault();
1915
+ // If A is playing, toggle A, else if B is playing, toggle B, else play A
1916
+ if (podcastWavePlayers.a && podcastWavePlayers.a.isPlaying) {
1917
+ podcastWavePlayers.a.togglePlayPause();
1918
+ } else if (podcastWavePlayers.b && podcastWavePlayers.b.isPlaying) {
1919
+ podcastWavePlayers.b.togglePlayPause();
1920
+ } else if (podcastWavePlayers.a) {
1921
+ podcastWavePlayers.a.play();
1922
+ }
1923
+ }
1924
+ }
1925
+ });
1926
+
1927
+ // Event listeners
1928
+ addLineBtn.addEventListener('click', function() {
1929
+ addPodcastLine();
1930
+ });
1931
+
1932
+ randomScriptBtn.addEventListener('click', function() {
1933
+ loadRandomScript();
1934
+ });
1935
+
1936
+ podcastSynthBtn.addEventListener('click', function() {
1937
+ generatePodcast();
1938
+ });
1939
+
1940
+ // Add event listeners to vote buttons
1941
+ podcastVoteButtons.forEach(btn => {
1942
+ btn.addEventListener('click', function() {
1943
+ if (bothPodcastSamplesPlayed) {
1944
+ const model = this.dataset.model;
1945
+ handlePodcastVote(model);
1946
+ } else {
1947
+ openToast("Please listen to both audio samples before voting", "info");
1948
+ }
1949
+ });
1950
+ });
1951
+
1952
+ // Add event listener for next round button
1953
+ podcastNextRoundBtn.addEventListener('click', resetPodcastState);
1954
+
1955
+ // Initialize with 2 empty lines
1956
+ initializePodcastLines();
1957
+ });
1958
+ </script>
1959
+ {% endblock %}
templates/base.html ADDED
@@ -0,0 +1,1446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>{% block title %}TTS Arena{% endblock %}</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
11
+ {% block extra_head %}{% endblock %}
12
+ <style>
13
+ :root {
14
+ --primary-color: #5046e5;
15
+ --secondary-color: #f0f0f0;
16
+ --text-color: #333;
17
+ --light-gray: #f5f5f5;
18
+ --border-color: #e0e0e0;
19
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
20
+ --radius: 8px;
21
+ --success-color: #10b981;
22
+ --info-color: #3b82f6;
23
+ --warning-color: #f59e0b;
24
+ --error-color: #ef4444;
25
+ }
26
+
27
+ * {
28
+ margin: 0;
29
+ padding: 0;
30
+ box-sizing: border-box;
31
+ font-family: 'Inter', sans-serif;
32
+ }
33
+
34
+ body {
35
+ color: var(--text-color);
36
+ display: flex;
37
+ min-height: 100vh;
38
+ height: 100vh;
39
+ overflow: hidden;
40
+ }
41
+
42
+ a {
43
+ color: var(--primary-color);
44
+ }
45
+
46
+ .sidebar {
47
+ width: 240px;
48
+ background-color: var(--light-gray);
49
+ padding: 24px 16px;
50
+ border-right: 1px solid var(--border-color);
51
+ display: flex;
52
+ flex-direction: column;
53
+ height: 100vh;
54
+ z-index: 1000;
55
+ transition: transform 0.3s ease-in-out;
56
+ flex-shrink: 0;
57
+ }
58
+
59
+ .logo {
60
+ font-size: 24px;
61
+ font-weight: 700;
62
+ margin-bottom: 32px;
63
+ color: var(--primary-color);
64
+ }
65
+
66
+ .nav-item {
67
+ display: flex;
68
+ align-items: center;
69
+ padding: 12px 16px;
70
+ margin-bottom: 8px;
71
+ border-radius: var(--radius);
72
+ cursor: pointer;
73
+ transition: background-color 0.2s;
74
+ color: var(--text-color);
75
+ text-decoration: none;
76
+ }
77
+
78
+ .nav-item.active {
79
+ background-color: rgba(80, 70, 229, 0.1);
80
+ color: var(--primary-color);
81
+ font-weight: 500;
82
+ }
83
+
84
+ .nav-item:hover:not(.active) {
85
+ background-color: rgba(0, 0, 0, 0.05);
86
+ }
87
+
88
+ .nav-item svg {
89
+ margin-right: 12px;
90
+ }
91
+
92
+ .main-content {
93
+ flex: 1;
94
+ padding: 32px;
95
+ width: 100%;
96
+ margin: 0 auto;
97
+ overflow-y: auto;
98
+ height: 100vh;
99
+ }
100
+
101
+ .main-content-inner {
102
+ max-width: 1200px;
103
+ width: 100%;
104
+ margin: 0 auto;
105
+ }
106
+
107
+ .tabs {
108
+ display: flex;
109
+ border-bottom: 1px solid var(--border-color);
110
+ margin-bottom: 24px;
111
+ }
112
+
113
+ .tab {
114
+ padding: 12px 24px;
115
+ cursor: pointer;
116
+ position: relative;
117
+ font-weight: 500;
118
+ }
119
+
120
+ .tab.active {
121
+ color: var(--primary-color);
122
+ }
123
+
124
+ .tab.active::after {
125
+ content: '';
126
+ position: absolute;
127
+ bottom: -1px;
128
+ left: 0;
129
+ width: 100%;
130
+ height: 2px;
131
+ background-color: var(--primary-color);
132
+ }
133
+
134
+ .input-container {
135
+ display: flex;
136
+ margin-bottom: 24px;
137
+ align-items: center;
138
+ }
139
+
140
+ .text-input {
141
+ flex: 1;
142
+ padding: 12px 16px;
143
+ border: 1px solid var(--border-color);
144
+ border-radius: var(--radius);
145
+ font-family: 'Inter', sans-serif;
146
+ font-size: 1em;
147
+ outline: none;
148
+ transition: border-color 0.2s;
149
+ }
150
+
151
+ .text-input:focus {
152
+ border-color: var(--primary-color);
153
+ }
154
+
155
+ .btn {
156
+ background-color: var(--primary-color);
157
+ color: white;
158
+ border: none;
159
+ border-radius: var(--radius);
160
+ padding: 12px 24px;
161
+ margin-left: 12px;
162
+ cursor: pointer;
163
+ font-weight: 500;
164
+ transition: background-color 0.2s;
165
+ }
166
+
167
+ .btn:hover {
168
+ background-color: #4038c7;
169
+ }
170
+
171
+ .icon-btn {
172
+ background-color: white;
173
+ border: 1px solid var(--border-color);
174
+ border-radius: var(--radius);
175
+ width: 42px;
176
+ height: 42px;
177
+ display: flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ margin-left: 12px;
181
+ cursor: pointer;
182
+ transition: background-color 0.2s;
183
+ }
184
+
185
+ .icon-btn:hover {
186
+ background-color: var(--light-gray);
187
+ }
188
+
189
+ .players-container {
190
+ display: flex;
191
+ flex-direction: column;
192
+ }
193
+
194
+ .players-row {
195
+ display: flex;
196
+ gap: 24px;
197
+ margin-bottom: 24px;
198
+ }
199
+
200
+ .player {
201
+ flex: 1;
202
+ border: 1px solid var(--border-color);
203
+ border-radius: var(--radius);
204
+ padding: 16px;
205
+ box-shadow: var(--shadow);
206
+ }
207
+
208
+ .player-label {
209
+ font-weight: 600;
210
+ margin-bottom: 12px;
211
+ }
212
+
213
+ .audio-player {
214
+ width: 100%;
215
+ margin-bottom: 16px;
216
+ }
217
+
218
+ .vote-btn {
219
+ width: 100%;
220
+ padding: 12px;
221
+ background-color: white;
222
+ border: 1px solid var(--border-color);
223
+ border-radius: var(--radius);
224
+ font-weight: 500;
225
+ cursor: pointer;
226
+ transition: all 0.2s;
227
+ position: relative;
228
+ }
229
+
230
+ .vote-btn:hover {
231
+ background-color: var(--light-gray);
232
+ border-color: #ccc;
233
+ }
234
+
235
+ .vote-btn.selected {
236
+ background-color: var(--primary-color);
237
+ color: white;
238
+ border-color: var(--primary-color);
239
+ }
240
+
241
+ .shortcut-key {
242
+ position: absolute;
243
+ right: 12px;
244
+ top: 50%;
245
+ transform: translateY(-50%);
246
+ background-color: var(--light-gray);
247
+ color: var(--text-color);
248
+ border: 1px solid var(--border-color);
249
+ border-radius: 4px;
250
+ padding: 2px 6px;
251
+ font-size: 12px;
252
+ font-weight: 600;
253
+ }
254
+
255
+ .vote-btn.selected .shortcut-key {
256
+ background-color: rgba(255, 255, 255, 0.2);
257
+ color: white;
258
+ border-color: transparent;
259
+ }
260
+
261
+ .user-auth {
262
+ margin-top: auto;
263
+ display: flex;
264
+ align-items: center;
265
+ padding: 12px 16px;
266
+ border-top: 1px solid var(--border-color);
267
+ cursor: pointer;
268
+ position: relative;
269
+ }
270
+
271
+ .user-avatar {
272
+ width: 32px;
273
+ height: 32px;
274
+ border-radius: 50%;
275
+ background-color: var(--primary-color);
276
+ display: flex;
277
+ align-items: center;
278
+ justify-content: center;
279
+ color: white;
280
+ font-weight: 600;
281
+ margin-right: 12px;
282
+ }
283
+
284
+ .user-name {
285
+ font-weight: 500;
286
+ flex: 1;
287
+ }
288
+
289
+ .user-dropdown {
290
+ position: absolute;
291
+ bottom: 100%;
292
+ left: 0;
293
+ right: 0;
294
+ margin: 0 16px;
295
+ background-color: white;
296
+ border: 1px solid var(--border-color);
297
+ border-radius: var(--radius);
298
+ box-shadow: var(--shadow);
299
+ z-index: 1000;
300
+ display: none;
301
+ overflow: hidden;
302
+ margin-bottom: 8px;
303
+ }
304
+
305
+ .user-dropdown.active {
306
+ display: block;
307
+ }
308
+
309
+ .dropdown-item {
310
+ padding: 12px 16px;
311
+ display: flex;
312
+ align-items: center;
313
+ transition: background-color 0.2s;
314
+ text-decoration: none;
315
+ color: var(--text-color);
316
+ }
317
+
318
+ .dropdown-item:hover {
319
+ background-color: var(--light-gray);
320
+ }
321
+
322
+ .dropdown-item svg {
323
+ margin-right: 12px;
324
+ }
325
+
326
+ .dropdown-divider {
327
+ height: 1px;
328
+ background-color: var(--border-color);
329
+ margin: 4px 0;
330
+ }
331
+
332
+ .user-auth-arrow {
333
+ transition: transform 0.2s;
334
+ }
335
+
336
+ .user-auth.active .user-auth-arrow {
337
+ transform: rotate(180deg);
338
+ }
339
+
340
+ .login-link {
341
+ display: flex;
342
+ align-items: center;
343
+ padding: 12px 16px;
344
+ border-top: 1px solid var(--border-color);
345
+ text-decoration: none;
346
+ color: var(--text-color);
347
+ }
348
+
349
+ .login-link:hover {
350
+ background-color: var(--light-gray);
351
+ }
352
+
353
+ .login-link img {
354
+ width: 24px;
355
+ height: 24px;
356
+ margin-right: 12px;
357
+ }
358
+
359
+ .discord-link {
360
+ display: flex;
361
+ align-items: center;
362
+ padding: 12px 16px;
363
+ border-top: 1px solid var(--border-color);
364
+ text-decoration: none;
365
+ color: var(--text-color);
366
+ }
367
+
368
+ .discord-link:hover {
369
+ background-color: var(--light-gray);
370
+ color: #5865F2;
371
+ }
372
+
373
+ .discord-link svg {
374
+ margin-right: 12px;
375
+ }
376
+
377
+ .sidebar-footer {
378
+ margin-top: auto;
379
+ display: flex;
380
+ flex-direction: column;
381
+ }
382
+
383
+ .mobile-header {
384
+ display: none;
385
+ align-items: center;
386
+ justify-content: space-between;
387
+ padding: 16px;
388
+ border-bottom: 1px solid var(--border-color);
389
+ }
390
+
391
+ .hamburger-menu {
392
+ width: 24px;
393
+ height: 24px;
394
+ cursor: pointer;
395
+ }
396
+
397
+ .current-page {
398
+ font-weight: 600;
399
+ font-size: 18px;
400
+ }
401
+
402
+ .backdrop {
403
+ display: none;
404
+ position: fixed;
405
+ top: 0;
406
+ left: 0;
407
+ width: 100%;
408
+ height: 100%;
409
+ background-color: rgba(0, 0, 0, 0.5);
410
+ -webkit-backdrop-filter: blur(3px);
411
+ backdrop-filter: blur(3px);
412
+ z-index: 999;
413
+ opacity: 0;
414
+ transition: opacity 0.3s ease-in-out;
415
+ }
416
+
417
+ .backdrop.active {
418
+ display: block;
419
+ opacity: 1;
420
+ }
421
+
422
+ .close-sidebar {
423
+ position: absolute;
424
+ top: 16px;
425
+ right: 16px;
426
+ width: 24px;
427
+ height: 24px;
428
+ cursor: pointer;
429
+ display: none;
430
+ }
431
+
432
+ /* Toast styles */
433
+ .toast-container {
434
+ position: fixed;
435
+ bottom: 24px;
436
+ right: 24px;
437
+ z-index: 9999;
438
+ display: flex;
439
+ flex-direction: column;
440
+ gap: 8px;
441
+ max-width: 350px;
442
+ }
443
+
444
+ .toast {
445
+ display: flex;
446
+ align-items: center;
447
+ padding: 12px 16px;
448
+ border-radius: 8px;
449
+ background-color: white;
450
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
451
+ animation: slideIn 0.3s ease-out forwards;
452
+ position: relative;
453
+ overflow: hidden;
454
+ }
455
+
456
+ .toast.slide-out {
457
+ animation: slideOut 0.3s ease-in forwards;
458
+ }
459
+
460
+ .toast-icon {
461
+ margin-right: 10px;
462
+ flex-shrink: 0;
463
+ display: flex;
464
+ align-items: center;
465
+ justify-content: center;
466
+ }
467
+
468
+ .toast-content {
469
+ flex: 1;
470
+ font-size: 14px;
471
+ font-weight: 500;
472
+ line-height: 1.4;
473
+ }
474
+
475
+ .toast-close {
476
+ margin-left: 10px;
477
+ cursor: pointer;
478
+ opacity: 0.5;
479
+ transition: opacity 0.2s;
480
+ flex-shrink: 0;
481
+ border-radius: 50%;
482
+ width: 20px;
483
+ height: 20px;
484
+ display: flex;
485
+ align-items: center;
486
+ justify-content: center;
487
+ }
488
+
489
+ .toast-close:hover {
490
+ opacity: 1;
491
+ background-color: rgba(0, 0, 0, 0.05);
492
+ }
493
+
494
+ .toast-progress {
495
+ position: absolute;
496
+ bottom: 0;
497
+ left: 0;
498
+ height: 2px;
499
+ width: 100%;
500
+ transform-origin: left;
501
+ }
502
+
503
+ .toast.info {
504
+ border-left-color: var(--info-color);
505
+ }
506
+
507
+ .toast.info .toast-icon {
508
+ color: var(--info-color);
509
+ }
510
+
511
+ .toast.info .toast-progress {
512
+ background-color: var(--info-color);
513
+ }
514
+
515
+ .toast.success .toast-icon {
516
+ color: var(--success-color);
517
+ }
518
+
519
+ .toast.success .toast-progress {
520
+ background-color: var(--success-color);
521
+ }
522
+
523
+ .toast.warning .toast-icon {
524
+ color: var(--warning-color);
525
+ }
526
+
527
+ .toast.warning .toast-progress {
528
+ background-color: var(--warning-color);
529
+ }
530
+
531
+ .toast.error .toast-icon {
532
+ color: var(--error-color);
533
+ }
534
+
535
+ .toast.error .toast-progress {
536
+ background-color: var(--error-color);
537
+ }
538
+
539
+ @keyframes slideIn {
540
+ from {
541
+ transform: translateX(100%);
542
+ opacity: 0;
543
+ }
544
+
545
+ to {
546
+ transform: translateX(0);
547
+ opacity: 1;
548
+ }
549
+ }
550
+
551
+ @keyframes slideOut {
552
+ from {
553
+ transform: translateX(0);
554
+ opacity: 1;
555
+ }
556
+
557
+ to {
558
+ transform: translateX(100%);
559
+ opacity: 0;
560
+ }
561
+ }
562
+
563
+ @keyframes shrink {
564
+ from {
565
+ transform: scaleX(1);
566
+ }
567
+ to {
568
+ transform: scaleX(0);
569
+ }
570
+ }
571
+
572
+ @media (max-width: 768px) {
573
+ body {
574
+ flex-direction: column;
575
+ }
576
+
577
+ .mobile-header {
578
+ display: flex;
579
+ flex-shrink: 0;
580
+ }
581
+
582
+ .sidebar {
583
+ position: fixed;
584
+ top: 0;
585
+ left: 0;
586
+ width: 280px;
587
+ border-right: 1px solid var(--border-color);
588
+ padding: 24px 16px;
589
+ height: 100vh;
590
+ transform: translateX(-100%);
591
+ }
592
+
593
+ .sidebar.active {
594
+ transform: translateX(0);
595
+ }
596
+
597
+ .close-sidebar {
598
+ display: block;
599
+ }
600
+
601
+ .logo {
602
+ display: block;
603
+ }
604
+
605
+ .players-container {
606
+ flex-direction: column;
607
+ }
608
+
609
+ .main-content {
610
+ height: calc(100vh - 57px);
611
+ overflow-y: auto;
612
+ }
613
+
614
+ .toast-container {
615
+ bottom: auto;
616
+ top: 16px;
617
+ right: 16px;
618
+ left: 16px;
619
+ max-width: none;
620
+ }
621
+
622
+ @keyframes slideIn {
623
+ from {
624
+ transform: translateY(-100%);
625
+ opacity: 0;
626
+ }
627
+
628
+ to {
629
+ transform: translateY(0);
630
+ opacity: 1;
631
+ }
632
+ }
633
+
634
+ @keyframes slideOut {
635
+ from {
636
+ transform: translateY(0);
637
+ opacity: 1;
638
+ }
639
+
640
+ to {
641
+ transform: translateY(-100%);
642
+ opacity: 0;
643
+ }
644
+ }
645
+ }
646
+
647
+ ::-webkit-scrollbar {
648
+ width: 8px;
649
+ height: 8px;
650
+ }
651
+
652
+ ::-webkit-scrollbar-track {
653
+ background: var(--light-gray);
654
+ border-radius: 4px;
655
+ }
656
+
657
+ ::-webkit-scrollbar-thumb {
658
+ background: rgba(120, 120, 120, 0.5);
659
+ border-radius: 4px;
660
+ transition: background 0.2s ease;
661
+ }
662
+
663
+ ::-webkit-scrollbar-thumb:hover {
664
+ background: rgba(100, 100, 100, 0.7);
665
+ }
666
+
667
+ /* Firefox scrollbar */
668
+ * {
669
+ scrollbar-width: thin;
670
+ scrollbar-color: rgba(120, 120, 120, 0.5) var(--light-gray);
671
+ }
672
+
673
+ /* For Edge and other browsers */
674
+ ::-webkit-scrollbar-corner {
675
+ background: var(--light-gray);
676
+ }
677
+
678
+ /* Ensure smooth scrolling */
679
+ html {
680
+ scroll-behavior: smooth;
681
+ }
682
+
683
+ /* Dark mode styles */
684
+ @media (prefers-color-scheme: dark) {
685
+ :root {
686
+ --primary-color: #6c63ff;
687
+ --secondary-color: #2d2b38;
688
+ --text-color: #e0e0e0;
689
+ --light-gray: #1e1e24;
690
+ --border-color: #3a3a45;
691
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
692
+ --success-color: #10b981;
693
+ --info-color: #60a5fa;
694
+ --warning-color: #f59e0b;
695
+ --error-color: #ef4444;
696
+ }
697
+
698
+ body {
699
+ background-color: #121218;
700
+ color: var(--text-color);
701
+ }
702
+
703
+ .sidebar {
704
+ background-color: var(--light-gray);
705
+ border-right-color: var(--border-color);
706
+ }
707
+
708
+ .nav-item.active {
709
+ background-color: rgba(108, 99, 255, 0.2);
710
+ }
711
+
712
+ .nav-item:hover:not(.active) {
713
+ background-color: rgba(255, 255, 255, 0.05);
714
+ }
715
+
716
+ .text-input,
717
+ .select-input,
718
+ .textarea {
719
+ background-color: var(--light-gray);
720
+ color: var(--text-color);
721
+ border-color: var(--border-color);
722
+ }
723
+
724
+ .card {
725
+ background-color: var(--light-gray);
726
+ border-color: var(--border-color);
727
+ }
728
+
729
+ .tab.active::after {
730
+ background-color: var(--primary-color);
731
+ }
732
+
733
+ /* Fix vote buttons in dark mode */
734
+ .vote-btn {
735
+ background-color: var(--light-gray);
736
+ color: var(--text-color);
737
+ border-color: var(--border-color);
738
+ border-radius: var(--radius);
739
+ }
740
+
741
+ .vote-btn:hover {
742
+ background-color: rgba(255, 255, 255, 0.1);
743
+ border-color: var(--border-color);
744
+ }
745
+
746
+ .vote-btn.selected {
747
+ background-color: var(--primary-color);
748
+ color: white;
749
+ border-color: var(--primary-color);
750
+ }
751
+
752
+ .shortcut-key {
753
+ background-color: rgba(255, 255, 255, 0.1);
754
+ color: var(--text-color);
755
+ border-color: var(--border-color);
756
+ }
757
+
758
+ .vote-btn.selected .shortcut-key {
759
+ background-color: rgba(255, 255, 255, 0.2);
760
+ color: white;
761
+ border-color: transparent;
762
+ }
763
+
764
+ /* Fix loading state in dark mode */
765
+ .vote-btn:disabled,
766
+ .vote-btn.loading {
767
+ background-color: var(--light-gray);
768
+ border-radius: var(--radius);
769
+ }
770
+
771
+ .vote-loader {
772
+ background-color: var(--light-gray);
773
+ border-radius: var(--radius);
774
+ }
775
+
776
+ .vote-spinner {
777
+ border-color: rgba(108, 99, 255, 0.3);
778
+ border-top-color: var(--primary-color);
779
+ }
780
+
781
+ .toast {
782
+ background-color: var(--light-gray);
783
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
784
+ }
785
+
786
+ .toast-close:hover {
787
+ background-color: rgba(255, 255, 255, 0.1);
788
+ }
789
+
790
+ ::-webkit-scrollbar-track {
791
+ background: var(--secondary-color);
792
+ }
793
+
794
+ ::-webkit-scrollbar-thumb {
795
+ background: rgba(180, 180, 180, 0.5);
796
+ }
797
+
798
+ ::-webkit-scrollbar-thumb:hover {
799
+ background: rgba(200, 200, 200, 0.7);
800
+ }
801
+
802
+ * {
803
+ scrollbar-color: rgba(180, 180, 180, 0.5) var(--secondary-color);
804
+ }
805
+
806
+ ::-webkit-scrollbar-corner {
807
+ background: var(--secondary-color);
808
+ }
809
+
810
+ /* Dark mode loading overlay */
811
+ .loading-overlay {
812
+ background-color: rgba(18, 18, 24, 0.8);
813
+ }
814
+
815
+ /* Dark mode spinner */
816
+ .loader-spinner {
817
+ border-color: rgba(108, 99, 255, 0.2);
818
+ border-top-color: var(--primary-color);
819
+ }
820
+
821
+ /* Dark mode user dropdown */
822
+ .user-dropdown {
823
+ background-color: var(--light-gray);
824
+ border-color: var(--border-color);
825
+ }
826
+
827
+ .dropdown-item {
828
+ color: var(--text-color);
829
+ }
830
+
831
+ .dropdown-item:hover {
832
+ background-color: rgba(108, 99, 255, 0.1);
833
+ }
834
+
835
+ .dropdown-divider {
836
+ background-color: var(--border-color);
837
+ }
838
+
839
+ .user-avatar {
840
+ background-color: var(--primary-color);
841
+ }
842
+ }
843
+
844
+ /* Loading Overlay */
845
+ .loading-overlay {
846
+ position: fixed;
847
+ top: 0;
848
+ left: 0;
849
+ width: 100%;
850
+ height: 100%;
851
+ background-color: rgba(255, 255, 255, 0.8);
852
+ display: flex;
853
+ justify-content: center;
854
+ align-items: center;
855
+ z-index: 9999;
856
+ opacity: 0;
857
+ visibility: hidden;
858
+ transition: opacity 0.3s ease, visibility 0.3s ease;
859
+ }
860
+
861
+ .loading-overlay.active {
862
+ opacity: 1;
863
+ visibility: visible;
864
+ }
865
+
866
+ .loader-spinner {
867
+ width: 50px;
868
+ height: 50px;
869
+ border: 3px solid rgba(80, 70, 229, 0.3);
870
+ border-radius: 50%;
871
+ border-top-color: var(--primary-color);
872
+ animation: spin 1s ease-in-out infinite;
873
+ }
874
+
875
+ @keyframes spin {
876
+ to {
877
+ transform: rotate(360deg);
878
+ }
879
+ }
880
+
881
+ /* Login tip overlay */
882
+ .login-tip-overlay {
883
+ position: absolute;
884
+ background-color: white;
885
+ border: 1px solid var(--border-color);
886
+ border-radius: var(--radius);
887
+ box-shadow: var(--shadow);
888
+ padding: 16px;
889
+ z-index: 1000;
890
+ width: 280px;
891
+ display: none;
892
+ }
893
+
894
+ .login-tip-overlay.show {
895
+ display: block;
896
+ }
897
+
898
+ .login-tip-content {
899
+ font-size: 14px;
900
+ margin-bottom: 12px;
901
+ }
902
+
903
+ .login-tip-actions {
904
+ display: flex;
905
+ justify-content: space-between;
906
+ }
907
+
908
+ .login-tip-close {
909
+ font-size: 13px;
910
+ color: var(--text-color);
911
+ opacity: 0.7;
912
+ cursor: pointer;
913
+ background: none;
914
+ border: none;
915
+ padding: 0;
916
+ }
917
+
918
+ .login-now-btn {
919
+ font-size: 13px;
920
+ background-color: var(--primary-color);
921
+ color: white;
922
+ border: none;
923
+ border-radius: 4px;
924
+ padding: 6px 12px;
925
+ cursor: pointer;
926
+ text-decoration: none;
927
+ }
928
+
929
+ .login-tip-overlay[data-popper-placement^='top'] .login-tip-caret {
930
+ position: absolute;
931
+ bottom: -8px;
932
+ left: 50%;
933
+ transform: translateX(-50%);
934
+ width: 16px;
935
+ height: 8px;
936
+ overflow: hidden;
937
+ }
938
+
939
+ .login-tip-overlay[data-popper-placement^='top'] .login-tip-caret:after {
940
+ content: '';
941
+ position: absolute;
942
+ width: 12px;
943
+ height: 12px;
944
+ background: white;
945
+ border-right: 1px solid var(--border-color);
946
+ border-bottom: 1px solid var(--border-color);
947
+ top: -6px;
948
+ left: 2px;
949
+ transform: rotate(45deg);
950
+ }
951
+
952
+ @media (prefers-color-scheme: dark) {
953
+ .login-tip-overlay {
954
+ background-color: var(--light-gray);
955
+ border-color: var(--border-color);
956
+ }
957
+
958
+ .login-tip-overlay[data-popper-placement^='top'] .login-tip-caret:after {
959
+ background: var(--light-gray);
960
+ border-color: var(--border-color);
961
+ }
962
+
963
+ .login-tip-close {
964
+ color: var(--text-color);
965
+ }
966
+ }
967
+
968
+ /* Mobile login banner */
969
+ .login-banner {
970
+ position: fixed;
971
+ top: 50%;
972
+ left: 50%;
973
+ transform: translate(-50%, -50%);
974
+ width: 85%;
975
+ max-width: 320px;
976
+ background-color: white;
977
+ color: var(--text-color);
978
+ border-radius: var(--radius);
979
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
980
+ padding: 20px;
981
+ display: none;
982
+ z-index: 9998;
983
+ text-align: center;
984
+ border: 1px solid var(--border-color);
985
+ }
986
+
987
+ .login-banner-content {
988
+ margin-bottom: 16px;
989
+ font-size: 15px;
990
+ font-weight: 500;
991
+ }
992
+
993
+ .login-banner-actions {
994
+ display: flex;
995
+ flex-direction: row;
996
+ justify-content: space-between;
997
+ gap: 12px;
998
+ align-items: center;
999
+ margin-top: 20px;
1000
+ }
1001
+
1002
+ .login-banner-close {
1003
+ background: none;
1004
+ border: 1px solid var(--border-color);
1005
+ color: var(--text-color);
1006
+ font-size: 14px;
1007
+ cursor: pointer;
1008
+ padding: 10px 16px;
1009
+ border-radius: var(--radius);
1010
+ flex: 1;
1011
+ font-weight: 500;
1012
+ }
1013
+
1014
+ .login-banner-btn {
1015
+ background-color: var(--primary-color);
1016
+ color: white;
1017
+ border: none;
1018
+ border-radius: var(--radius);
1019
+ padding: 10px 16px;
1020
+ cursor: pointer;
1021
+ font-weight: 500;
1022
+ text-decoration: none;
1023
+ flex: 1;
1024
+ text-align: center;
1025
+ }
1026
+
1027
+ @media (prefers-color-scheme: dark) {
1028
+ .login-banner {
1029
+ background-color: var(--light-gray);
1030
+ border-color: var(--border-color);
1031
+ }
1032
+
1033
+ .login-banner-close {
1034
+ border-color: var(--border-color);
1035
+ background-color: rgba(255, 255, 255, 0.05);
1036
+ }
1037
+ }
1038
+ </style>
1039
+ </head>
1040
+
1041
+ <body>
1042
+ <!-- Loading Overlay -->
1043
+ <div id="loading-overlay" class="loading-overlay">
1044
+ <div class="loader-spinner"></div>
1045
+ </div>
1046
+
1047
+ <div class="mobile-header">
1048
+ <div class="hamburger-menu" onclick="toggleSidebar()">
1049
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1050
+ <path d="M4 6H20M4 12H20M4 18H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
1051
+ </svg>
1052
+ </div>
1053
+ <div class="current-page">{% block current_page %}Arena{% endblock %}</div>
1054
+ </div>
1055
+
1056
+ <div class="backdrop" onclick="toggleSidebar()"></div>
1057
+
1058
+ <div class="sidebar">
1059
+ <div class="close-sidebar" onclick="toggleSidebar()">
1060
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1061
+ <path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
1062
+ </svg>
1063
+ </div>
1064
+ <div class="logo">TTS Arena</div>
1065
+ <nav>
1066
+ <a href="{{ url_for('arena') }}" class="nav-item {% if request.path == '/' %}active{% endif %}">
1067
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-dices"><rect width="12" height="12" x="2" y="10" rx="2" ry="2"/><path d="m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6"/><path d="M6 18h.01"/><path d="M10 14h.01"/><path d="M15 6h.01"/><path d="M18 9h.01"/></svg>
1068
+ Arena
1069
+ </a>
1070
+ <a href="{{ url_for('leaderboard') }}" class="nav-item {% if request.path == '/leaderboard' %}active{% endif %}">
1071
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trophy"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>
1072
+ Leaderboard
1073
+ </a>
1074
+ <a href="{{ url_for('about') }}" class="nav-item {% if request.path == '/about' %}active{% endif %}">
1075
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
1076
+ About
1077
+ </a>
1078
+
1079
+ <!-- Admin Panel Link - Only visible to admin users -->
1080
+ {% if g.is_admin %}
1081
+ <a href="{{ url_for('admin.index') }}" class="nav-item {% if '/admin' in request.path %}active{% endif %}">
1082
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/></svg>
1083
+ Admin Panel
1084
+ </a>
1085
+ {% endif %}
1086
+ </nav>
1087
+
1088
+ <div class="sidebar-footer">
1089
+ <a href="https://discord.gg/HB8fMR6GTr" target="_blank" rel="noopener noreferrer" class="discord-link">
1090
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 127.14 96.36" fill="currentColor">
1091
+ <path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
1092
+ </svg>
1093
+ Join our Discord
1094
+ </a>
1095
+
1096
+ {% if current_user.is_authenticated %}
1097
+ <div class="user-auth" onclick="toggleUserDropdown(event)">
1098
+ <div class="user-name">{{ current_user.username }}</div>
1099
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="user-auth-arrow">
1100
+ <polyline points="6 9 12 15 18 9"></polyline>
1101
+ </svg>
1102
+
1103
+ <div class="user-dropdown">
1104
+ <a href="{{ url_for('auth.logout') }}" class="dropdown-item">
1105
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1106
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
1107
+ <polyline points="16 17 21 12 16 7"></polyline>
1108
+ <line x1="21" y1="12" x2="9" y2="12"></line>
1109
+ </svg>
1110
+ Logout
1111
+ </a>
1112
+ </div>
1113
+ </div>
1114
+ {% else %}
1115
+ <a href="{{ url_for('auth.login', next=request.path) }}" class="login-link">
1116
+ <img src="{{ url_for('static', filename='huggingface.svg') }}" alt="Hugging Face">
1117
+ Login
1118
+ </a>
1119
+ <!-- Login tip overlay -->
1120
+ <div id="login-tip-overlay" class="login-tip-overlay">
1121
+ <div class="login-tip-content">
1122
+ Log in to track your votes, see personalized leaderboards, and more!
1123
+ </div>
1124
+ <div class="login-tip-actions">
1125
+ <button class="login-tip-close" onclick="dismissLoginTip()">Don't show again</button>
1126
+ <a href="{{ url_for('auth.login', next=request.path) }}" class="login-now-btn">Login now</a>
1127
+ </div>
1128
+ <div class="login-tip-caret"></div>
1129
+ </div>
1130
+ {% endif %}
1131
+ </div>
1132
+ </div>
1133
+
1134
+ <div class="main-content">
1135
+ <!-- Flash messages -->
1136
+ {% with messages = get_flashed_messages(with_categories=true) %}
1137
+ {% if messages %}
1138
+ <div class="flash-messages">
1139
+ {% for category, message in messages %}
1140
+ <script>
1141
+ document.addEventListener('DOMContentLoaded', function () {
1142
+ openToast('{{ message }}', '{{ category }}');
1143
+ });
1144
+ </script>
1145
+ {% endfor %}
1146
+ </div>
1147
+ {% endif %}
1148
+ {% endwith %}
1149
+
1150
+ <div class="main-content-inner">
1151
+ {% block content %}{% endblock %}
1152
+ </div>
1153
+ </div>
1154
+
1155
+ <!-- Toast container -->
1156
+ <div class="toast-container" id="toast-container"></div>
1157
+
1158
+ {% if not current_user.is_authenticated %}
1159
+ <!-- Mobile login banner -->
1160
+ <div id="login-banner" class="login-banner">
1161
+ <div class="login-banner-content">
1162
+ Log in to track your votes and see personalized leaderboards!
1163
+ </div>
1164
+ <div class="login-banner-actions">
1165
+ <button class="login-banner-close" onclick="dismissLoginTip()">No thanks</button>
1166
+ <a href="{{ url_for('auth.login', next=request.path) }}" class="login-banner-btn">Login</a>
1167
+ </div>
1168
+ </div>
1169
+ {% endif %}
1170
+
1171
+ {% block extra_scripts %}{% endblock %}
1172
+ <script src="https://unpkg.com/@popperjs/core@2"></script>
1173
+ <script>
1174
+ function toggleSidebar() {
1175
+ const sidebar = document.querySelector('.sidebar');
1176
+ const backdrop = document.querySelector('.backdrop');
1177
+ sidebar.classList.toggle('active');
1178
+ backdrop.classList.toggle('active');
1179
+ }
1180
+
1181
+ function toggleUserDropdown(event) {
1182
+ event.stopPropagation();
1183
+ const userAuth = document.querySelector('.user-auth');
1184
+ const userDropdown = document.querySelector('.user-dropdown');
1185
+ userAuth.classList.toggle('active');
1186
+ userDropdown.classList.toggle('active');
1187
+ }
1188
+
1189
+ // Function to check if the login tip has been dismissed
1190
+ function isLoginTipDismissed() {
1191
+ try {
1192
+ return localStorage.getItem('login_tip_dismissed') === 'true';
1193
+ } catch (error) {
1194
+ // Fallback if localStorage is blocked
1195
+ console.warn('localStorage access failed:', error);
1196
+ return false;
1197
+ }
1198
+ }
1199
+
1200
+ // Function to set localStorage when login tip is dismissed
1201
+ function dismissLoginTip() {
1202
+ try {
1203
+ // Store the preference in localStorage
1204
+ localStorage.setItem('login_tip_dismissed', 'true');
1205
+
1206
+ // Hide all login notifications
1207
+ const loginTip = document.getElementById('login-tip-overlay');
1208
+ const loginBanner = document.getElementById('login-banner');
1209
+ const backdrop = document.querySelector('.login-backdrop');
1210
+
1211
+ if (loginTip) {
1212
+ loginTip.classList.remove('show');
1213
+ }
1214
+
1215
+ if (loginBanner) {
1216
+ loginBanner.style.display = 'none';
1217
+ }
1218
+
1219
+ if (backdrop) {
1220
+ backdrop.style.display = 'none';
1221
+ }
1222
+ } catch (error) {
1223
+ console.warn('localStorage write failed:', error);
1224
+ // Still hide the tips even if localStorage fails
1225
+ const loginTip = document.getElementById('login-tip-overlay');
1226
+ const loginBanner = document.getElementById('login-banner');
1227
+ const backdrop = document.querySelector('.login-backdrop');
1228
+
1229
+ if (loginTip) {
1230
+ loginTip.classList.remove('show');
1231
+ }
1232
+
1233
+ if (loginBanner) {
1234
+ loginBanner.style.display = 'none';
1235
+ }
1236
+
1237
+ if (backdrop) {
1238
+ backdrop.style.display = 'none';
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ // Loading overlay functionality
1244
+ document.addEventListener('DOMContentLoaded', function () {
1245
+ // Show login tip if user is not logged in and hasn't dismissed it
1246
+ const loginTipOverlay = document.getElementById('login-tip-overlay');
1247
+ const loginBanner = document.getElementById('login-banner');
1248
+ const loginLink = document.querySelector('.login-link');
1249
+
1250
+ if (loginLink && !isLoginTipDismissed()) {
1251
+ // Check screen width to determine which login notification to show
1252
+ if (window.innerWidth <= 768) {
1253
+ // Create and add a backdrop for the login banner
1254
+ const backdrop = document.createElement('div');
1255
+ backdrop.className = 'login-backdrop';
1256
+ backdrop.style.position = 'fixed';
1257
+ backdrop.style.top = '0';
1258
+ backdrop.style.left = '0';
1259
+ backdrop.style.width = '100%';
1260
+ backdrop.style.height = '100%';
1261
+ backdrop.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
1262
+ backdrop.style.zIndex = '9997';
1263
+ backdrop.style.display = 'none';
1264
+ document.body.appendChild(backdrop);
1265
+
1266
+ // Show mobile banner with backdrop
1267
+ if (loginBanner) {
1268
+ loginBanner.style.display = 'block';
1269
+ backdrop.style.display = 'block';
1270
+
1271
+ // Add event listener to close banner when clicking backdrop
1272
+ backdrop.addEventListener('click', function() {
1273
+ dismissLoginTip();
1274
+ });
1275
+ }
1276
+ } else {
1277
+ // Show desktop popover
1278
+ if (loginTipOverlay) {
1279
+ // Position the overlay with Popper.js
1280
+ const popperInstance = Popper.createPopper(loginLink, loginTipOverlay, {
1281
+ placement: 'top',
1282
+ modifiers: [
1283
+ {
1284
+ name: 'offset',
1285
+ options: {
1286
+ offset: [0, 10],
1287
+ },
1288
+ },
1289
+ ],
1290
+ });
1291
+
1292
+ loginTipOverlay.classList.add('show');
1293
+ popperInstance.update();
1294
+ }
1295
+ }
1296
+ }
1297
+
1298
+ // Handle resize events to switch between banner and popover
1299
+ window.addEventListener('resize', function() {
1300
+ if (isLoginTipDismissed()) return;
1301
+
1302
+ const backdrop = document.querySelector('.login-backdrop');
1303
+
1304
+ if (window.innerWidth <= 768) {
1305
+ // Switch to mobile banner
1306
+ if (loginTipOverlay) {
1307
+ loginTipOverlay.classList.remove('show');
1308
+ }
1309
+ if (loginBanner && backdrop) {
1310
+ loginBanner.style.display = 'block';
1311
+ backdrop.style.display = 'block';
1312
+ }
1313
+ } else {
1314
+ // Switch to desktop popover
1315
+ if (loginBanner && backdrop) {
1316
+ loginBanner.style.display = 'none';
1317
+ backdrop.style.display = 'none';
1318
+ }
1319
+ if (loginTipOverlay && loginLink) {
1320
+ const popperInstance = Popper.createPopper(loginLink, loginTipOverlay, {
1321
+ placement: 'top',
1322
+ modifiers: [
1323
+ {
1324
+ name: 'offset',
1325
+ options: {
1326
+ offset: [0, 10],
1327
+ },
1328
+ },
1329
+ ],
1330
+ });
1331
+
1332
+ loginTipOverlay.classList.add('show');
1333
+ popperInstance.update();
1334
+ }
1335
+ }
1336
+ });
1337
+
1338
+ // Hide the loading overlay when page has loaded
1339
+ const loadingOverlay = document.getElementById('loading-overlay');
1340
+ loadingOverlay.classList.remove('active');
1341
+
1342
+ // Override fetch to handle Turnstile verification errors
1343
+ const originalFetch = window.fetch;
1344
+ window.fetch = async function (url, options) {
1345
+ try {
1346
+ const response = await originalFetch(url, options);
1347
+
1348
+ // If we get a 403 error with a specific error message, handle verification
1349
+ if (response.status === 403) {
1350
+ const data = await response.clone().json();
1351
+ if (data && (data.error === "Turnstile verification required" || data.error === "Turnstile verification expired")) {
1352
+ // Redirect to Turnstile verification page with the current URL as the redirect target
1353
+ window.location.href = "/turnstile?redirect_url=" + encodeURIComponent(window.location.href);
1354
+ return new Response(JSON.stringify({ redirecting: true }), {
1355
+ status: 200,
1356
+ headers: { 'Content-Type': 'application/json' }
1357
+ });
1358
+ }
1359
+ }
1360
+
1361
+ return response;
1362
+ } catch (error) {
1363
+ return Promise.reject(error);
1364
+ }
1365
+ };
1366
+ });
1367
+
1368
+ // Close dropdown when clicking outside
1369
+ document.addEventListener('click', function (event) {
1370
+ const userDropdown = document.querySelector('.user-dropdown');
1371
+ const userAuth = document.querySelector('.user-auth');
1372
+ if (userDropdown && userAuth && userDropdown.classList.contains('active') && !userAuth.contains(event.target)) {
1373
+ userAuth.classList.remove('active');
1374
+ userDropdown.classList.remove('active');
1375
+ }
1376
+ });
1377
+
1378
+ // Toast functionality
1379
+ function openToast(message, type = 'info', duration = 5000) {
1380
+ const toastContainer = document.getElementById('toast-container');
1381
+ const toast = document.createElement('div');
1382
+ toast.className = `toast ${type}`;
1383
+
1384
+ // Generate icon based on type
1385
+ let iconSvg = '';
1386
+ if (type === 'info') {
1387
+ iconSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>';
1388
+ } else if (type === 'success') {
1389
+ iconSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
1390
+ } else if (type === 'warning') {
1391
+ iconSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17"/></svg>';
1392
+ } else if (type === 'error') {
1393
+ iconSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
1394
+ }
1395
+
1396
+ toast.innerHTML = `
1397
+ <div class="toast-icon">${iconSvg}</div>
1398
+ <div class="toast-content">${message}</div>
1399
+ <div class="toast-close" onclick="closeToast(this.parentNode)">
1400
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1401
+ <line x1="18" y1="6" x2="6" y2="18"></line>
1402
+ <line x1="6" y1="6" x2="18" y2="18"></line>
1403
+ </svg>
1404
+ </div>
1405
+ <div class="toast-progress"></div>
1406
+ `;
1407
+
1408
+ toastContainer.appendChild(toast);
1409
+
1410
+ // Animate progress bar
1411
+ const progressBar = toast.querySelector('.toast-progress');
1412
+ progressBar.style.animation = `shrink ${duration / 1000}s linear forwards`;
1413
+ progressBar.style.transformOrigin = 'left';
1414
+ progressBar.style.transform = 'scaleX(1)';
1415
+
1416
+ // Auto-remove toast after duration
1417
+ const timeoutId = setTimeout(() => {
1418
+ closeToast(toast);
1419
+ }, duration);
1420
+
1421
+ // Store timeout ID on the toast element
1422
+ toast.dataset.timeoutId = timeoutId;
1423
+
1424
+ return toast;
1425
+ }
1426
+
1427
+ function closeToast(toast) {
1428
+ // Clear the timeout to prevent duplicate removal attempts
1429
+ if (toast.dataset.timeoutId) {
1430
+ clearTimeout(parseInt(toast.dataset.timeoutId));
1431
+ }
1432
+
1433
+ // Add slide-out animation
1434
+ toast.classList.add('slide-out');
1435
+
1436
+ // Remove toast after animation completes
1437
+ setTimeout(() => {
1438
+ if (toast.parentNode) {
1439
+ toast.parentNode.removeChild(toast);
1440
+ }
1441
+ }, 300);
1442
+ }
1443
+ </script>
1444
+ </body>
1445
+
1446
+ </html>
templates/email.html ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Email Preferences - TTS Arena</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
11
+ <style>
12
+ :root {
13
+ --primary-color: #5046e5;
14
+ --secondary-color: #f0f0f0;
15
+ --text-color: #333;
16
+ --light-gray: #f5f5f5;
17
+ --border-color: #e0e0e0;
18
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
19
+ --radius: 8px;
20
+ --success-color: #10b981;
21
+ --info-color: #3b82f6;
22
+ --warning-color: #f59e0b;
23
+ --error-color: #ef4444;
24
+ }
25
+
26
+ * {
27
+ margin: 0;
28
+ padding: 0;
29
+ box-sizing: border-box;
30
+ font-family: 'Inter', sans-serif;
31
+ }
32
+
33
+ body {
34
+ color: var(--text-color);
35
+ background-color: var(--light-gray);
36
+ min-height: 100vh;
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: center;
40
+ padding: 20px;
41
+ }
42
+
43
+ .container {
44
+ max-width: 600px;
45
+ width: 100%;
46
+ background-color: white;
47
+ border-radius: var(--radius);
48
+ box-shadow: var(--shadow);
49
+ padding: 40px;
50
+ }
51
+
52
+ .logo {
53
+ font-size: 24px;
54
+ font-weight: 700;
55
+ margin-bottom: 24px;
56
+ color: var(--primary-color);
57
+ text-align: center;
58
+ }
59
+
60
+ h1 {
61
+ font-size: 24px;
62
+ margin-bottom: 16px;
63
+ text-align: center;
64
+ }
65
+
66
+ p {
67
+ margin-bottom: 24px;
68
+ line-height: 1.6;
69
+ }
70
+
71
+ .btn-container {
72
+ display: flex;
73
+ gap: 16px;
74
+ margin-top: 32px;
75
+ }
76
+
77
+ .btn {
78
+ flex: 1;
79
+ background-color: var(--primary-color);
80
+ color: white;
81
+ border: none;
82
+ border-radius: var(--radius);
83
+ padding: 12px 24px;
84
+ cursor: pointer;
85
+ font-weight: 500;
86
+ transition: background-color 0.2s;
87
+ text-align: center;
88
+ text-decoration: none;
89
+ display: inline-block;
90
+ }
91
+
92
+ .btn:hover {
93
+ background-color: #4038c7;
94
+ }
95
+
96
+ .btn-secondary {
97
+ background-color: white;
98
+ color: var(--text-color);
99
+ border: 1px solid var(--border-color);
100
+ }
101
+
102
+ .btn-secondary:hover {
103
+ background-color: var(--light-gray);
104
+ }
105
+
106
+ .footer {
107
+ margin-top: 40px;
108
+ text-align: center;
109
+ font-size: 14px;
110
+ color: #666;
111
+ }
112
+
113
+ .footer a {
114
+ color: var(--primary-color);
115
+ text-decoration: none;
116
+ }
117
+
118
+ /* Responsive styles */
119
+ @media (max-width: 768px) {
120
+ .container {
121
+ padding: 30px 20px;
122
+ }
123
+
124
+ h1 {
125
+ font-size: 22px;
126
+ }
127
+ }
128
+
129
+ @media (max-width: 480px) {
130
+ .btn-container {
131
+ flex-direction: column;
132
+ gap: 12px;
133
+ }
134
+
135
+ .btn {
136
+ width: 100%;
137
+ }
138
+
139
+ .container {
140
+ padding: 25px 15px;
141
+ }
142
+
143
+ h1 {
144
+ font-size: 20px;
145
+ }
146
+
147
+ .logo {
148
+ font-size: 22px;
149
+ margin-bottom: 20px;
150
+ }
151
+ }
152
+ </style>
153
+ </head>
154
+
155
+ <body>
156
+ <div class="container">
157
+ <div class="logo">TTS Arena</div>
158
+ <h1>Email Updates</h1>
159
+ <p>
160
+ Would you mind receiving occasional email updates about TTS Arena? We'll keep you informed about new models,
161
+ improvements, and important announcements.
162
+ </p>
163
+ <p>
164
+ We respect your privacy and promise not to spam your inbox. You can unsubscribe at any time. If you choose not to receive updates, we won't ask you again.
165
+ </p>
166
+ <div class="btn-container">
167
+ <a href="{{ url_for('subscribe_email', choice='yes') }}" class="btn">Yes, I'd like updates</a>
168
+ <a href="{{ url_for('subscribe_email', choice='no') }}" class="btn btn-secondary">No, thanks</a>
169
+ </div>
170
+ </div>
171
+ </body>
172
+
173
+ </html>
174
+
templates/leaderboard.html ADDED
@@ -0,0 +1,1413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Leaderboard - TTS Arena{% endblock %}
4
+
5
+ {% block current_page %}Leaderboard{% endblock %}
6
+
7
+ {% block extra_head %}
8
+ <style>
9
+ .leaderboard-container {
10
+ background: white;
11
+ border-radius: var(--radius);
12
+ box-shadow: var(--shadow);
13
+ overflow: hidden;
14
+ width: 100%;
15
+ overflow-x: auto; /* Allow horizontal scrolling on small screens */
16
+ }
17
+
18
+ .leaderboard-header {
19
+ display: grid;
20
+ grid-template-columns: 80px 1fr 120px 120px 120px;
21
+ padding: 16px;
22
+ background-color: var(--light-gray);
23
+ border-bottom: 1px solid var(--border-color);
24
+ font-weight: 600;
25
+ min-width: 600px; /* Ensure minimum width for the grid */
26
+ }
27
+
28
+ .leaderboard-row {
29
+ display: grid;
30
+ grid-template-columns: 80px 1fr 120px 120px 120px;
31
+ padding: 16px;
32
+ border-bottom: 1px solid var(--border-color);
33
+ align-items: center;
34
+ min-width: 600px; /* Ensure minimum width for the grid */
35
+ }
36
+
37
+ .leaderboard-row:last-child {
38
+ border-bottom: none;
39
+ }
40
+
41
+ .rank {
42
+ font-weight: 600;
43
+ color: var(--primary-color);
44
+ }
45
+
46
+ .model-name {
47
+ font-weight: 500;
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 6px;
51
+ }
52
+
53
+ .model-name-link {
54
+ text-decoration: none;
55
+ color: var(--text-color);
56
+ }
57
+
58
+ .model-name-link:hover {
59
+ text-decoration: underline;
60
+ }
61
+
62
+ .license-icon {
63
+ width: 12px;
64
+ height: 12px;
65
+ cursor: help;
66
+ position: relative;
67
+ opacity: 0.7;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ }
72
+
73
+ .license-icon img {
74
+ width: 12px;
75
+ height: 12px;
76
+ vertical-align: middle;
77
+ }
78
+
79
+ .tooltip {
80
+ visibility: hidden;
81
+ background-color: rgba(0, 0, 0, 0.8);
82
+ color: white;
83
+ text-align: center;
84
+ border-radius: 4px;
85
+ padding: 5px 10px;
86
+ position: absolute;
87
+ z-index: 1;
88
+ bottom: 125%;
89
+ left: 50%;
90
+ transform: translateX(-50%);
91
+ opacity: 0;
92
+ transition: opacity 0.3s;
93
+ font-weight: normal;
94
+ font-size: 12px;
95
+ white-space: nowrap;
96
+ }
97
+
98
+ .license-icon:hover .tooltip {
99
+ visibility: visible;
100
+ opacity: 1;
101
+ }
102
+
103
+ .win-rate, .total-votes, .elo-score {
104
+ text-align: right;
105
+ color: #666;
106
+ }
107
+
108
+ .elo-score {
109
+ font-weight: 600;
110
+ }
111
+
112
+ .tier-s {
113
+ background-color: rgba(255, 215, 0, 0.1);
114
+ }
115
+
116
+ .tier-a {
117
+ background-color: rgba(80, 200, 120, 0.1);
118
+ }
119
+
120
+ .tier-b {
121
+ background-color: rgba(80, 70, 229, 0.1);
122
+ }
123
+
124
+ .filter-controls {
125
+ display: flex;
126
+ margin-bottom: 24px;
127
+ align-items: center;
128
+ gap: 16px;
129
+ }
130
+
131
+ .tabs {
132
+ display: flex;
133
+ border-bottom: 1px solid var(--border-color);
134
+ margin-bottom: 24px;
135
+ }
136
+
137
+ .tab {
138
+ padding: 12px 24px;
139
+ cursor: pointer;
140
+ position: relative;
141
+ font-weight: 500;
142
+ }
143
+
144
+ .tab.active {
145
+ color: var(--primary-color);
146
+ }
147
+
148
+ .tab.active::after {
149
+ content: '';
150
+ position: absolute;
151
+ bottom: -1px;
152
+ left: 0;
153
+ width: 100%;
154
+ height: 2px;
155
+ background-color: var(--primary-color);
156
+ }
157
+
158
+ .coming-soon {
159
+ text-align: center;
160
+ padding: 60px 0;
161
+ color: #666;
162
+ font-size: 18px;
163
+ font-weight: 500;
164
+ }
165
+
166
+ .no-data {
167
+ text-align: center;
168
+ padding: 40px 0;
169
+ color: #666;
170
+ }
171
+
172
+ .no-data h3 {
173
+ margin-bottom: 12px;
174
+ color: #333;
175
+ }
176
+
177
+ .no-data p {
178
+ margin-bottom: 20px;
179
+ max-width: 500px;
180
+ margin-left: auto;
181
+ margin-right: auto;
182
+ }
183
+
184
+ .view-toggle {
185
+ display: flex;
186
+ justify-content: flex-end;
187
+ margin-bottom: 20px;
188
+ }
189
+
190
+ .segmented-control {
191
+ position: relative;
192
+ display: inline-flex;
193
+ background-color: var(--light-gray);
194
+ border-radius: 8px;
195
+ padding: 4px;
196
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
197
+ -webkit-user-select: none;
198
+ user-select: none;
199
+ }
200
+
201
+ .segmented-control input[type="radio"] {
202
+ display: none;
203
+ }
204
+
205
+ .segmented-control label {
206
+ position: relative;
207
+ z-index: 2;
208
+ padding: 8px 20px;
209
+ font-size: 14px;
210
+ font-weight: 500;
211
+ text-align: center;
212
+ cursor: pointer;
213
+ transition: color 0.2s ease;
214
+ color: #666;
215
+ border-radius: 6px;
216
+ }
217
+
218
+ .segmented-control label:hover {
219
+ color: #333;
220
+ }
221
+
222
+ .segmented-control input[type="radio"]:checked + label {
223
+ color: #fff;
224
+ }
225
+
226
+ .slider {
227
+ position: absolute;
228
+ z-index: 1;
229
+ top: 4px;
230
+ left: 4px;
231
+ height: calc(100% - 8px);
232
+ border-radius: 6px;
233
+ transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
234
+ background-color: var(--primary-color);
235
+ }
236
+
237
+ .login-prompt {
238
+ display: none;
239
+ position: fixed;
240
+ top: 0;
241
+ left: 0;
242
+ width: 100%;
243
+ height: 100%;
244
+ background-color: rgba(0,0,0,0.7);
245
+ z-index: 9999;
246
+ justify-content: center;
247
+ align-items: center;
248
+ }
249
+
250
+ .login-prompt-content {
251
+ background-color: var(--light-gray);
252
+ padding: 24px;
253
+ border-radius: var(--radius);
254
+ box-shadow: var(--shadow);
255
+ text-align: center;
256
+ max-width: 400px;
257
+ position: relative;
258
+ }
259
+
260
+ .login-prompt-content h3 {
261
+ margin-bottom: 16px;
262
+ }
263
+
264
+ .login-prompt-content p {
265
+ margin-bottom: 24px;
266
+ }
267
+
268
+ .login-prompt-close {
269
+ position: absolute;
270
+ top: 12px;
271
+ right: 12px;
272
+ font-size: 20px;
273
+ cursor: pointer;
274
+ color: #999;
275
+ }
276
+
277
+ .btn {
278
+ display: inline-block;
279
+ background-color: var(--primary-color);
280
+ color: white;
281
+ padding: 8px 16px;
282
+ border-radius: 4px;
283
+ text-decoration: none;
284
+ font-weight: 500;
285
+ }
286
+
287
+ /* Timeline styles */
288
+ .timeline-container {
289
+ margin-bottom: 24px;
290
+ position: relative;
291
+ }
292
+
293
+ .timeline-header {
294
+ display: flex;
295
+ justify-content: space-between;
296
+ align-items: center;
297
+ margin-bottom: 16px;
298
+ }
299
+
300
+ .timeline-title {
301
+ font-weight: 600;
302
+ color: var(--text-color);
303
+ display: flex;
304
+ align-items: center;
305
+ gap: 8px;
306
+ }
307
+
308
+ .timeline-title svg {
309
+ opacity: 0.7;
310
+ }
311
+
312
+ .timeline-controls {
313
+ display: flex;
314
+ align-items: center;
315
+ gap: 8px;
316
+ }
317
+
318
+ .timeline-select {
319
+ padding: 8px 12px;
320
+ border-radius: 4px;
321
+ border: 1px solid var(--border-color);
322
+ background-color: white;
323
+ color: var(--text-color);
324
+ font-size: 14px;
325
+ appearance: none;
326
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
327
+ background-repeat: no-repeat;
328
+ background-position: right 8px center;
329
+ background-size: 16px;
330
+ padding-right: 36px;
331
+ cursor: pointer;
332
+ }
333
+
334
+ .timeline-button {
335
+ padding: 8px 12px;
336
+ border-radius: 4px;
337
+ border: 1px solid var(--border-color);
338
+ background-color: white;
339
+ color: var(--text-color);
340
+ font-size: 14px;
341
+ cursor: pointer;
342
+ display: flex;
343
+ align-items: center;
344
+ gap: 4px;
345
+ }
346
+
347
+ .timeline-button:hover {
348
+ background-color: var(--light-gray);
349
+ }
350
+
351
+ .timeline-track {
352
+ height: 8px;
353
+ background-color: var(--light-gray);
354
+ border-radius: 4px;
355
+ position: relative;
356
+ margin: 20px 0;
357
+ }
358
+
359
+ .timeline-progress {
360
+ position: absolute;
361
+ height: 100%;
362
+ background-color: var(--primary-color);
363
+ border-radius: 4px;
364
+ transition: width 0.3s ease;
365
+ }
366
+
367
+ .timeline-marker {
368
+ position: absolute;
369
+ width: 16px;
370
+ height: 16px;
371
+ background-color: white;
372
+ border: 3px solid var(--primary-color);
373
+ border-radius: 50%;
374
+ top: 50%;
375
+ transform: translate(-50%, -50%);
376
+ cursor: pointer;
377
+ z-index: 2;
378
+ transition: left 0.3s ease;
379
+ }
380
+
381
+ .timeline-dates {
382
+ display: flex;
383
+ justify-content: space-between;
384
+ margin-top: 8px;
385
+ color: #666;
386
+ font-size: 12px;
387
+ }
388
+
389
+ .historical-indicator {
390
+ display: none;
391
+ background-color: var(--primary-color);
392
+ color: white;
393
+ padding: 4px 10px;
394
+ border-radius: 12px;
395
+ font-size: 12px;
396
+ font-weight: 500;
397
+ margin-bottom: 16px;
398
+ align-items: center;
399
+ gap: 6px;
400
+ }
401
+
402
+ .historical-indicator.active {
403
+ display: inline-flex;
404
+ }
405
+
406
+ .loading-spinner {
407
+ display: none;
408
+ width: 20px;
409
+ height: 20px;
410
+ border-radius: 50%;
411
+ border: 2px solid rgba(255, 255, 255, 0.3);
412
+ border-top-color: white;
413
+ animation: spin 1s linear infinite;
414
+ }
415
+
416
+ .loading.loading-spinner {
417
+ display: inline-block;
418
+ }
419
+
420
+ @keyframes spin {
421
+ to { transform: rotate(360deg); }
422
+ }
423
+
424
+ @media (max-width: 768px) {
425
+ .leaderboard-header, .leaderboard-row {
426
+ grid-template-columns: 60px 1fr 80px 80px;
427
+ min-width: 400px; /* Reduced minimum width for mobile */
428
+ }
429
+
430
+ .total-votes {
431
+ display: none;
432
+ }
433
+
434
+ .leaderboard-header .total-votes-header {
435
+ display: none;
436
+ }
437
+
438
+ .filter-controls {
439
+ flex-direction: column;
440
+ align-items: flex-start;
441
+ }
442
+
443
+ .timeline-header {
444
+ flex-direction: column;
445
+ align-items: flex-start;
446
+ gap: 12px;
447
+ }
448
+
449
+ .timeline-controls {
450
+ width: 100%;
451
+ }
452
+
453
+ .timeline-select {
454
+ flex-grow: 1;
455
+ }
456
+ }
457
+
458
+ @media (max-width: 480px) {
459
+ .leaderboard-header, .leaderboard-row {
460
+ grid-template-columns: 50px 1fr 70px;
461
+ min-width: 300px; /* Further reduced for very small screens */
462
+ font-size: 14px;
463
+ padding: 12px 8px;
464
+ }
465
+
466
+ .elo-score {
467
+ font-size: 14px;
468
+ }
469
+
470
+ .total-votes, .win-rate {
471
+ display: none;
472
+ }
473
+
474
+ .leaderboard-header .total-votes-header,
475
+ .leaderboard-header div:nth-child(3) {
476
+ display: none;
477
+ }
478
+
479
+ .tab {
480
+ padding: 10px 16px;
481
+ font-size: 14px;
482
+ }
483
+ }
484
+
485
+ /* Dark mode styles */
486
+ @media (prefers-color-scheme: dark) {
487
+ .no-data {
488
+ color: var(--text-color);
489
+ }
490
+
491
+ .no-data h3 {
492
+ color: var(--text-color);
493
+ }
494
+
495
+
496
+ .leaderboard-container {
497
+ background-color: var(--light-gray);
498
+ border-color: var(--border-color);
499
+ }
500
+
501
+ .leaderboard-header {
502
+ background-color: rgba(80, 70, 229, 0.1);
503
+ border-color: var(--border-color);
504
+ }
505
+
506
+ .leaderboard-row {
507
+ border-color: var(--border-color);
508
+ }
509
+
510
+ .leaderboard-row:hover {
511
+ background-color: rgba(255, 255, 255, 0.05);
512
+ }
513
+
514
+ .tier-s {
515
+ background-color: rgba(255, 215, 0, 0.1);
516
+ }
517
+
518
+ .tier-a {
519
+ background-color: rgba(192, 192, 192, 0.1);
520
+ }
521
+
522
+ .tier-b {
523
+ background-color: rgba(205, 127, 50, 0.1);
524
+ }
525
+
526
+ .segmented-control {
527
+ background-color: var(--light-gray);
528
+ border-color: var(--border-color);
529
+ }
530
+
531
+ .segmented-control label {
532
+ color: var(--text-color);
533
+ }
534
+
535
+ .segmented-control label:hover {
536
+ color: var(--text-color);
537
+ }
538
+
539
+ .segmented-control .slider {
540
+ background-color: var(--primary-color);
541
+ }
542
+
543
+ .tooltip {
544
+ background-color: var(--light-gray);
545
+ color: var(--text-color);
546
+ border-color: var(--border-color);
547
+ }
548
+ .license-icon img {
549
+ filter: invert(1);
550
+ }
551
+
552
+ .timeline-select, .timeline-button {
553
+ background-color: var(--light-gray);
554
+ border-color: var(--border-color);
555
+ color: var(--text-color);
556
+ }
557
+
558
+ .timeline-button:hover {
559
+ background-color: rgba(255, 255, 255, 0.1);
560
+ }
561
+
562
+ .timeline-track {
563
+ background-color: rgba(255, 255, 255, 0.1);
564
+ }
565
+
566
+ .timeline-marker {
567
+ background-color: var(--light-gray);
568
+ }
569
+ }
570
+
571
+ /* Top voters leaderboard styles */
572
+ .voters-leaderboard {
573
+ margin-top: 32px;
574
+ }
575
+
576
+ .voters-leaderboard-header {
577
+ display: flex;
578
+ justify-content: space-between;
579
+ align-items: center;
580
+ margin-bottom: 16px;
581
+ }
582
+
583
+ .visibility-toggle {
584
+ display: flex;
585
+ align-items: center;
586
+ gap: 8px;
587
+ }
588
+
589
+ .toggle-switch {
590
+ position: relative;
591
+ display: inline-block;
592
+ width: 48px;
593
+ height: 24px;
594
+ }
595
+
596
+ .toggle-switch input {
597
+ opacity: 0;
598
+ width: 0;
599
+ height: 0;
600
+ }
601
+
602
+ .toggle-slider {
603
+ position: absolute;
604
+ cursor: pointer;
605
+ top: 0;
606
+ left: 0;
607
+ right: 0;
608
+ bottom: 0;
609
+ background-color: #ccc;
610
+ transition: .4s;
611
+ border-radius: 24px;
612
+ }
613
+
614
+ .toggle-slider:before {
615
+ position: absolute;
616
+ content: "";
617
+ height: 18px;
618
+ width: 18px;
619
+ left: 3px;
620
+ bottom: 3px;
621
+ background-color: white;
622
+ transition: .4s;
623
+ border-radius: 50%;
624
+ }
625
+
626
+ input:checked + .toggle-slider {
627
+ background-color: var(--primary-color);
628
+ }
629
+
630
+ input:checked + .toggle-slider:before {
631
+ transform: translateX(24px);
632
+ }
633
+
634
+ .toggle-label {
635
+ font-size: 14px;
636
+ color: var(--text-color);
637
+ }
638
+
639
+ .voters-table {
640
+ width: 100%;
641
+ border-collapse: collapse;
642
+ }
643
+
644
+ .voters-table th, .voters-table td {
645
+ padding: 12px 16px;
646
+ text-align: left;
647
+ border-bottom: 1px solid var(--border-color);
648
+ }
649
+
650
+ .voters-table tr:last-child td {
651
+ border-bottom: none;
652
+ }
653
+
654
+ .voters-table tr.current-user {
655
+ background-color: rgba(80, 70, 229, 0.1);
656
+ }
657
+
658
+ .voters-table tr.current-user td {
659
+ font-weight: 500;
660
+ }
661
+
662
+ .thank-you-message {
663
+ /* text-align: center; */
664
+ margin-top: 24px;
665
+ padding: 16px;
666
+ background-color: rgba(80, 200, 120, 0.1);
667
+ border-radius: var(--radius);
668
+ font-size: 16px;
669
+ }
670
+
671
+ @media (prefers-color-scheme: dark) {
672
+ .thank-you-message {
673
+ background-color: rgba(80, 200, 120, 0.2);
674
+ }
675
+ }
676
+
677
+ .voters-table th {
678
+ font-weight: 600;
679
+ color: var(--text-color);
680
+ background-color: var(--light-gray);
681
+ }
682
+
683
+ .voters-table tbody tr:hover {
684
+ background-color: var(--light-gray);
685
+ }
686
+
687
+ .login-prompt {
688
+ text-align: center;
689
+ padding: 24px;
690
+ background-color: var(--light-gray);
691
+ border-radius: var(--radius);
692
+ margin-top: 16px;
693
+ }
694
+
695
+ .no-voters-msg {
696
+ text-align: center;
697
+ padding: 24px;
698
+ color: var(--text-color);
699
+ }
700
+ </style>
701
+ {% endblock %}
702
+
703
+ {% block content %}
704
+ <div class="tabs">
705
+ <div class="tab active" data-tab="tts">TTS</div>
706
+ <div class="tab" data-tab="conversational">Conversational</div>
707
+ <div class="tab" data-tab="voters">Top Voters</div>
708
+ </div>
709
+
710
+ <div id="tts-tab" class="tab-content">
711
+ <div class="view-toggle">
712
+ <div class="segmented-control">
713
+ <input type="radio" id="tts-public" name="tts-view" checked>
714
+ <label for="tts-public">Public</label>
715
+ <input type="radio" id="tts-personal" name="tts-view">
716
+ <label for="tts-personal">Personal</label>
717
+ <div class="slider"></div>
718
+ </div>
719
+ </div>
720
+
721
+ <!-- Historical timeline for TTS models - temporarily disabled -->
722
+ <div id="tts-timeline-container" class="timeline-container" style="display: none;">
723
+ <div class="timeline-header">
724
+ <div class="timeline-title">
725
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
726
+ <circle cx="12" cy="12" r="10"></circle>
727
+ <polyline points="12 6 12 12 16 14"></polyline>
728
+ </svg>
729
+ Leaderboard History
730
+ </div>
731
+
732
+ <div class="timeline-controls">
733
+ <select id="tts-date-select" class="timeline-select">
734
+ {% if formatted_tts_dates %}
735
+ {% for date in formatted_tts_dates %}
736
+ <option value="{{ tts_key_dates[loop.index0].strftime('%Y-%m-%d') }}">{{ date }}</option>
737
+ {% endfor %}
738
+ {% else %}
739
+ <option value="">No historical data</option>
740
+ {% endif %}
741
+ </select>
742
+
743
+ <button id="tts-load-historical" class="timeline-button">
744
+ <span>Load</span>
745
+ <span id="tts-loading-spinner" class="loading-spinner"></span>
746
+ </button>
747
+ </div>
748
+ </div>
749
+
750
+ {% if tts_key_dates and tts_key_dates|length > 1 %}
751
+ <div class="timeline-track">
752
+ <div id="tts-timeline-progress" class="timeline-progress" style="width: 0%"></div>
753
+ <div id="tts-timeline-marker" class="timeline-marker" style="left: 0%"></div>
754
+ </div>
755
+ <div class="timeline-dates">
756
+ <div>{{ tts_key_dates[0].strftime('%b %Y') }}</div>
757
+ <div>{{ tts_key_dates[-1].strftime('%b %Y') }}</div>
758
+ </div>
759
+ {% else %}
760
+ <div class="no-data">
761
+ <p>Not enough historical data available to show timeline.</p>
762
+ </div>
763
+ {% endif %}
764
+ </div>
765
+
766
+ <!-- Historical indicator - temporarily disabled -->
767
+ <div class="historical-indicator" id="tts-historical-indicator" style="display: none;">
768
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
769
+ <circle cx="12" cy="12" r="10"></circle>
770
+ <polyline points="12 6 12 12 16 14"></polyline>
771
+ </svg>
772
+ <span id="tts-historical-date">Historical view</span>
773
+ </div>
774
+
775
+ <div id="tts-public-leaderboard" class="leaderboard-view active">
776
+ {% if tts_leaderboard and tts_leaderboard|length > 0 %}
777
+ <div class="leaderboard-container">
778
+ <div class="leaderboard-header">
779
+ <div>Rank</div>
780
+ <div>Model</div>
781
+ <div style="text-align: right">Win Rate</div>
782
+ <div style="text-align: right" class="total-votes-header">Total Votes</div>
783
+ <div style="text-align: right">ELO</div>
784
+ </div>
785
+
786
+ {% for model in tts_leaderboard %}
787
+ <div class="leaderboard-row {{ model.tier }}">
788
+ <div class="rank">#{{ model.rank }}</div>
789
+ <div class="model-name">
790
+ <a href="{{ model.model_url }}" target="_blank" class="model-name-link">{{ model.name }}</a>
791
+ <div class="license-icon">
792
+ {% if model.is_open %}
793
+ <img src="{{ url_for('static', filename='open.svg') }}" alt="Open">
794
+ <span class="tooltip">Open model</span>
795
+ {% else %}
796
+ <img src="{{ url_for('static', filename='closed.svg') }}" alt="Proprietary">
797
+ <span class="tooltip">Proprietary model</span>
798
+ {% endif %}
799
+ </div>
800
+ </div>
801
+ <div class="win-rate">{{ model.win_rate }}</div>
802
+ <div class="total-votes">{{ model.total_votes }}</div>
803
+ <div class="elo-score">{{ model.elo }}</div>
804
+ </div>
805
+ {% endfor %}
806
+ </div>
807
+ {% else %}
808
+ <div class="no-data">
809
+ <h3>No data available yet</h3>
810
+ <p>Be the first to vote and help build the leaderboard! Compare models in the arena to see how they stack up.</p>
811
+ <a href="{{ url_for('arena') }}" class="btn">Go to Arena</a>
812
+ </div>
813
+ {% endif %}
814
+ </div>
815
+
816
+ <div id="tts-personal-leaderboard" class="leaderboard-view" style="display: none;">
817
+ {% if current_user.is_authenticated and tts_personal_leaderboard and tts_personal_leaderboard|length > 0 %}
818
+ <div class="leaderboard-container">
819
+ <div class="leaderboard-header">
820
+ <div>Rank</div>
821
+ <div>Model</div>
822
+ <div style="text-align: right">Win Rate</div>
823
+ <div style="text-align: right" class="total-votes-header">Total Votes</div>
824
+ <div style="text-align: right">Wins</div>
825
+ </div>
826
+
827
+ {% for model in tts_personal_leaderboard %}
828
+ <div class="leaderboard-row">
829
+ <div class="rank">#{{ model.rank }}</div>
830
+ <div class="model-name">
831
+ <a href="{{ model.model_url }}" target="_blank" class="model-name-link">{{ model.name }}</a>
832
+ <div class="license-icon">
833
+ {% if model.is_open %}
834
+ <img src="{{ url_for('static', filename='open.svg') }}" alt="Open">
835
+ <span class="tooltip">Open model</span>
836
+ {% else %}
837
+ <img src="{{ url_for('static', filename='closed.svg') }}" alt="Proprietary">
838
+ <span class="tooltip">Proprietary model</span>
839
+ {% endif %}
840
+ </div>
841
+ </div>
842
+ <div class="win-rate">{{ model.win_rate }}</div>
843
+ <div class="total-votes">{{ model.total_votes }}</div>
844
+ <div class="elo-score">{{ model.wins }}</div>
845
+ </div>
846
+ {% endfor %}
847
+ </div>
848
+ {% else %}
849
+ <div class="no-data">
850
+ <h3>No personal data yet</h3>
851
+ <p>You haven't voted on any TTS models yet. Visit the arena to compare models and build your personal leaderboard.</p>
852
+ <a href="{{ url_for('arena') }}" class="btn">Go to Arena</a>
853
+ </div>
854
+ {% endif %}
855
+ </div>
856
+
857
+ <!-- Historical TTS leaderboard - temporarily disabled -->
858
+ <div id="tts-historical-leaderboard" class="leaderboard-view" style="display: none;">
859
+ <!-- This will be populated dynamically with JavaScript -->
860
+ <div class="leaderboard-container">
861
+ <div class="leaderboard-header">
862
+ <div>Rank</div>
863
+ <div>Model</div>
864
+ <div style="text-align: right">Win Rate</div>
865
+ <div style="text-align: right" class="total-votes-header">Total Votes</div>
866
+ <div style="text-align: right">ELO</div>
867
+ </div>
868
+ <div id="tts-historical-rows">
869
+ <!-- Historical rows will be inserted here -->
870
+ <div class="no-data">
871
+ <p>Select a date and click "Load" to view historical data.</p>
872
+ </div>
873
+ </div>
874
+ </div>
875
+ </div>
876
+ </div>
877
+
878
+ <div id="conversational-tab" class="tab-content" style="display: none;">
879
+ <div class="view-toggle">
880
+ <div class="segmented-control">
881
+ <input type="radio" id="conversational-public" name="conversational-view" checked>
882
+ <label for="conversational-public">Public</label>
883
+ <input type="radio" id="conversational-personal" name="conversational-view">
884
+ <label for="conversational-personal">Personal</label>
885
+ <div class="slider"></div>
886
+ </div>
887
+ </div>
888
+
889
+ <!-- Historical timeline for Conversational models - temporarily disabled -->
890
+ <div id="conversational-timeline-container" class="timeline-container" style="display: none;">
891
+ <div class="timeline-header">
892
+ <div class="timeline-title">
893
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
894
+ <circle cx="12" cy="12" r="10"></circle>
895
+ <polyline points="12 6 12 12 16 14"></polyline>
896
+ </svg>
897
+ Leaderboard History
898
+ </div>
899
+
900
+ <div class="timeline-controls">
901
+ <select id="conversational-date-select" class="timeline-select">
902
+ {% if formatted_conversational_dates %}
903
+ {% for date in formatted_conversational_dates %}
904
+ <option value="{{ conversational_key_dates[loop.index0].strftime('%Y-%m-%d') }}">{{ date }}</option>
905
+ {% endfor %}
906
+ {% else %}
907
+ <option value="">No historical data</option>
908
+ {% endif %}
909
+ </select>
910
+
911
+ <button id="conversational-load-historical" class="timeline-button">
912
+ <span>Load</span>
913
+ <span id="conversational-loading-spinner" class="loading-spinner"></span>
914
+ </button>
915
+ </div>
916
+ </div>
917
+
918
+ {% if conversational_key_dates and conversational_key_dates|length > 1 %}
919
+ <div class="timeline-track">
920
+ <div id="conversational-timeline-progress" class="timeline-progress" style="width: 0%"></div>
921
+ <div id="conversational-timeline-marker" class="timeline-marker" style="left: 0%"></div>
922
+ </div>
923
+ <div class="timeline-dates">
924
+ <div>{{ conversational_key_dates[0].strftime('%b %Y') }}</div>
925
+ <div>{{ conversational_key_dates[-1].strftime('%b %Y') }}</div>
926
+ </div>
927
+ {% else %}
928
+ <div class="no-data">
929
+ <p>Not enough historical data available to show timeline.</p>
930
+ </div>
931
+ {% endif %}
932
+ </div>
933
+
934
+ <!-- Historical indicator - temporarily disabled -->
935
+ <div class="historical-indicator" id="conversational-historical-indicator" style="display: none;">
936
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
937
+ <circle cx="12" cy="12" r="10"></circle>
938
+ <polyline points="12 6 12 12 16 14"></polyline>
939
+ </svg>
940
+ <span id="conversational-historical-date">Historical view</span>
941
+ </div>
942
+
943
+ <div id="conversational-public-leaderboard" class="leaderboard-view active">
944
+ {% if conversational_leaderboard and conversational_leaderboard|length > 0 %}
945
+ <div class="leaderboard-container">
946
+ <div class="leaderboard-header">
947
+ <div>Rank</div>
948
+ <div>Model</div>
949
+ <div style="text-align: right">Win Rate</div>
950
+ <div style="text-align: right" class="total-votes-header">Total Votes</div>
951
+ <div style="text-align: right">ELO</div>
952
+ </div>
953
+
954
+ {% for model in conversational_leaderboard %}
955
+ <div class="leaderboard-row {{ model.tier }}">
956
+ <div class="rank">#{{ model.rank }}</div>
957
+ <div class="model-name">
958
+ {{ model.name }}
959
+ <div class="license-icon">
960
+ {% if model.is_open %}
961
+ <img src="{{ url_for('static', filename='open.svg') }}" alt="Open">
962
+ <span class="tooltip">Open model</span>
963
+ {% else %}
964
+ <img src="{{ url_for('static', filename='closed.svg') }}" alt="Proprietary">
965
+ <span class="tooltip">Proprietary model</span>
966
+ {% endif %}
967
+ </div>
968
+ </div>
969
+ <div class="win-rate">{{ model.win_rate }}</div>
970
+ <div class="total-votes">{{ model.total_votes }}</div>
971
+ <div class="elo-score">{{ model.elo }}</div>
972
+ </div>
973
+ {% endfor %}
974
+ </div>
975
+ {% else %}
976
+ <div class="no-data">
977
+ <h3>No data available yet</h3>
978
+ <p>Be the first to vote and help build the conversational leaderboard! Compare models in the arena to see how they stack up.</p>
979
+ <a href="{{ url_for('arena') }}#conversational" class="btn">Go to Arena</a>
980
+ </div>
981
+ {% endif %}
982
+ </div>
983
+
984
+ <div id="conversational-personal-leaderboard" class="leaderboard-view" style="display: none;">
985
+ {% if current_user.is_authenticated and conversational_personal_leaderboard and conversational_personal_leaderboard|length > 0 %}
986
+ <div class="leaderboard-container">
987
+ <div class="leaderboard-header">
988
+ <div>Rank</div>
989
+ <div>Model</div>
990
+ <div style="text-align: right">Win Rate</div>
991
+ <div style="text-align: right" class="total-votes-header">Total Votes</div>
992
+ <div style="text-align: right">Wins</div>
993
+ </div>
994
+
995
+ {% for model in conversational_personal_leaderboard %}
996
+ <div class="leaderboard-row">
997
+ <div class="rank">#{{ model.rank }}</div>
998
+ <div class="model-name">
999
+ {{ model.name }}
1000
+ <div class="license-icon">
1001
+ {% if model.is_open %}
1002
+ <img src="{{ url_for('static', filename='open.svg') }}" alt="Open">
1003
+ <span class="tooltip">Open model</span>
1004
+ {% else %}
1005
+ <img src="{{ url_for('static', filename='closed.svg') }}" alt="Proprietary">
1006
+ <span class="tooltip">Proprietary model</span>
1007
+ {% endif %}
1008
+ </div>
1009
+ </div>
1010
+ <div class="win-rate">{{ model.win_rate }}</div>
1011
+ <div class="total-votes">{{ model.total_votes }}</div>
1012
+ <div class="elo-score">{{ model.wins }}</div>
1013
+ </div>
1014
+ {% endfor %}
1015
+ </div>
1016
+ {% else %}
1017
+ <div class="no-data">
1018
+ <h3>No personal data yet</h3>
1019
+ <p>You haven't voted on any conversational models yet. Visit the arena to compare models and build your personal leaderboard.</p>
1020
+ <a href="{{ url_for('arena') }}#conversational" class="btn">Go to Arena</a>
1021
+ </div>
1022
+ {% endif %}
1023
+ </div>
1024
+
1025
+ <!-- Historical Conversational leaderboard - temporarily disabled -->
1026
+ <div id="conversational-historical-leaderboard" class="leaderboard-view" style="display: none;">
1027
+ <!-- This will be populated dynamically with JavaScript -->
1028
+ <div class="leaderboard-container">
1029
+ <div class="leaderboard-header">
1030
+ <div>Rank</div>
1031
+ <div>Model</div>
1032
+ <div style="text-align: right">Win Rate</div>
1033
+ <div style="text-align: right" class="total-votes-header">Total Votes</div>
1034
+ <div style="text-align: right">ELO</div>
1035
+ </div>
1036
+ <div id="conversational-historical-rows">
1037
+ <!-- Historical rows will be inserted here -->
1038
+ <div class="no-data">
1039
+ <p>Select a date and click "Load" to view historical data.</p>
1040
+ </div>
1041
+ </div>
1042
+ </div>
1043
+ </div>
1044
+ </div>
1045
+
1046
+ <!-- Add Top Voters Tab -->
1047
+ <div id="voters-tab" class="tab-content" style="display: none;">
1048
+ <div class="voters-leaderboard">
1049
+ <div class="voters-leaderboard-header">
1050
+ <h2>Top Voters</h2>
1051
+ {% if current_user.is_authenticated %}
1052
+ <div class="visibility-toggle">
1053
+ <span class="toggle-label">Show me in leaderboard</span>
1054
+ <label class="toggle-switch">
1055
+ <input type="checkbox" id="visibility-toggle" {% if user_leaderboard_visibility %}checked{% endif %}>
1056
+ <span class="toggle-slider"></span>
1057
+ </label>
1058
+ </div>
1059
+ {% endif %}
1060
+ </div>
1061
+
1062
+ {% if top_voters %}
1063
+ <div class="leaderboard-container">
1064
+ <table class="voters-table">
1065
+ <thead>
1066
+ <tr>
1067
+ <th>Rank</th>
1068
+ <th>Username</th>
1069
+ <th>Total Votes</th>
1070
+ <th>Joined</th>
1071
+ </tr>
1072
+ </thead>
1073
+ <tbody>
1074
+ {% for voter in top_voters %}
1075
+ <tr{% if current_user.is_authenticated and current_user.username == voter.username %} class="current-user"{% endif %}>
1076
+ <td>{{ voter.rank }}</td>
1077
+ <td><a href="https://huggingface.co/{{ voter.username }}" target="_blank" rel="noopener">{{ voter.username }}</a></td>
1078
+ <td>{{ voter.vote_count }}</td>
1079
+ <td>{{ voter.join_date }}</td>
1080
+ </tr>
1081
+ {% endfor %}
1082
+ </tbody>
1083
+ </table>
1084
+ </div>
1085
+ {% else %}
1086
+ <div class="no-data">
1087
+ <p>No voters data available yet. Start voting to appear on the leaderboard!</p>
1088
+ </div>
1089
+ {% endif %}
1090
+
1091
+ {% if top_voters %}
1092
+ <div class="thank-you-message">
1093
+ <p>Thank you to all our voters for helping improve TTS Arena! Your contributions make this community better.</p>
1094
+ </div>
1095
+ {% endif %}
1096
+
1097
+ {% if not current_user.is_authenticated %}
1098
+ <div class="login-prompt">
1099
+ <p>Log in to appear on the leaderboard and track your voting stats!</p>
1100
+ <div class="button-container" style="margin-top: 16px;">
1101
+ <a href="{{ url_for('auth.login') }}" class="btn">Log In</a>
1102
+ </div>
1103
+ </div>
1104
+ {% endif %}
1105
+ </div>
1106
+ </div>
1107
+
1108
+ <div class="login-prompt">
1109
+ <div class="login-prompt-content">
1110
+ <div class="login-prompt-close">&times;</div>
1111
+ <h3>Login Required</h3>
1112
+ <p>You need to be logged in to view your personal leaderboard.</p>
1113
+ <a href="{{ url_for('auth.login', next=request.path) }}" class="btn">Login with Hugging Face</a>
1114
+ </div>
1115
+ </div>
1116
+
1117
+ <!-- Pass auth status via data attribute -->
1118
+ <div id="auth-data" data-is-logged-in="{% if current_user.is_authenticated %}true{% else %}false{% endif %}"></div>
1119
+
1120
+ <script>
1121
+ // Set auth status from server-side
1122
+ var isLoggedIn = document.getElementById('auth-data').dataset.isLoggedIn === 'true';
1123
+ </script>
1124
+
1125
+ <script>
1126
+ document.addEventListener('DOMContentLoaded', function() {
1127
+ // Initialize slider positions
1128
+ const ttsSlider = document.querySelector('#tts-tab .slider');
1129
+ const convSlider = document.querySelector('#conversational-tab .slider');
1130
+
1131
+ // Function to position sliders based on selected radio
1132
+ function positionSliders() {
1133
+ // Position TTS slider
1134
+ if (ttsSlider) {
1135
+ const ttsSelectedRadio = document.querySelector('#tts-tab input[name="tts-view"]:checked');
1136
+ if (ttsSelectedRadio) {
1137
+ const ttsSelectedLabel = document.querySelector(`label[for="${ttsSelectedRadio.id}"]`);
1138
+ ttsSlider.style.width = `${ttsSelectedLabel.offsetWidth}px`;
1139
+ ttsSlider.style.transform = `translateX(${ttsSelectedLabel.offsetLeft - 4}px)`;
1140
+ }
1141
+ }
1142
+
1143
+ // Position Conversational slider
1144
+ if (convSlider) {
1145
+ const convSelectedRadio = document.querySelector('#conversational-tab input[name="conversational-view"]:checked');
1146
+ if (convSelectedRadio) {
1147
+ const convSelectedLabel = document.querySelector(`label[for="${convSelectedRadio.id}"]`);
1148
+ convSlider.style.width = `${convSelectedLabel.offsetWidth}px`;
1149
+ convSlider.style.transform = `translateX(${convSelectedLabel.offsetLeft - 4}px)`;
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ // Position sliders on load
1155
+ positionSliders();
1156
+
1157
+ // Tab switching
1158
+ const tabs = document.querySelectorAll('.tab');
1159
+ const tabContents = document.querySelectorAll('.tab-content');
1160
+
1161
+ // Check URL hash for direct tab access
1162
+ function checkHashAndSetTab() {
1163
+ const hash = window.location.hash.toLowerCase();
1164
+ if (hash === '#conversational') {
1165
+ // Switch to conversational tab
1166
+ tabs.forEach(t => t.classList.remove('active'));
1167
+ tabContents.forEach(c => c.style.display = 'none');
1168
+
1169
+ document.querySelector('.tab[data-tab="conversational"]').classList.add('active');
1170
+ document.getElementById('conversational-tab').style.display = 'block';
1171
+
1172
+ // Ensure sliders are positioned correctly
1173
+ setTimeout(positionSliders, 50);
1174
+ } else if (hash === '#tts' || hash === '') {
1175
+ // Switch to TTS tab (default)
1176
+ tabs.forEach(t => t.classList.remove('active'));
1177
+ tabContents.forEach(c => c.style.display = 'none');
1178
+
1179
+ document.querySelector('.tab[data-tab="tts"]').classList.add('active');
1180
+ document.getElementById('tts-tab').style.display = 'block';
1181
+
1182
+ // Ensure sliders are positioned correctly
1183
+ setTimeout(positionSliders, 50);
1184
+ }
1185
+ }
1186
+
1187
+ // Check hash on page load
1188
+ checkHashAndSetTab();
1189
+
1190
+ // Listen for hash changes
1191
+ window.addEventListener('hashchange', checkHashAndSetTab);
1192
+
1193
+ tabs.forEach(tab => {
1194
+ tab.addEventListener('click', function() {
1195
+ const tabId = this.dataset.tab;
1196
+
1197
+ // Update URL hash without page reload
1198
+ history.replaceState(null, null, `#${tabId}`);
1199
+
1200
+ // Remove active class from all tabs and hide all contents
1201
+ tabs.forEach(t => t.classList.remove('active'));
1202
+ tabContents.forEach(c => c.style.display = 'none');
1203
+
1204
+ // Add active class to clicked tab and show corresponding content
1205
+ this.classList.add('active');
1206
+ document.getElementById(tabId + '-tab').style.display = 'block';
1207
+
1208
+ // Position sliders after tab switch
1209
+ setTimeout(positionSliders, 0);
1210
+ });
1211
+ });
1212
+
1213
+ // View toggle functionality
1214
+ const viewToggles = document.querySelectorAll('.segmented-control input[type="radio"]');
1215
+ const loginPrompt = document.querySelector('.login-prompt');
1216
+ const loginPromptClose = document.querySelector('.login-prompt-close');
1217
+
1218
+ viewToggles.forEach(toggle => {
1219
+ toggle.addEventListener('change', function() {
1220
+ const view = this.id.split('-')[1]; // 'public', 'personal', or 'historical'
1221
+ const tabId = this.closest('.tab-content').id.split('-')[0]; // 'tts' or 'conversational'
1222
+
1223
+ if (view === 'personal' && !isLoggedIn) {
1224
+ // Show login prompt
1225
+ loginPrompt.style.display = 'flex';
1226
+ // Reset the radio button to public
1227
+ document.getElementById(`${tabId}-public`).checked = true;
1228
+ return;
1229
+ }
1230
+
1231
+ // Position the slider using our function
1232
+ positionSliders();
1233
+
1234
+ // Show corresponding leaderboard
1235
+ const leaderboardViews = document.querySelectorAll(`#${tabId}-tab .leaderboard-view`);
1236
+ leaderboardViews.forEach(v => {
1237
+ v.style.display = 'none';
1238
+ v.classList.remove('active');
1239
+ });
1240
+ const activeView = document.getElementById(`${tabId}-${view}-leaderboard`);
1241
+ activeView.style.display = 'block';
1242
+ activeView.classList.add('active');
1243
+
1244
+ // Toggle timeline visibility - temporarily disabled
1245
+ /*
1246
+ const timelineContainer = document.getElementById(`${tabId}-timeline-container`);
1247
+ if (timelineContainer) {
1248
+ timelineContainer.style.display = view === 'historical' ? 'block' : 'none';
1249
+ }
1250
+ */
1251
+ });
1252
+ });
1253
+
1254
+ // Close login prompt
1255
+ if (loginPromptClose) {
1256
+ loginPromptClose.addEventListener('click', function() {
1257
+ loginPrompt.style.display = 'none';
1258
+ });
1259
+ }
1260
+
1261
+ // Historical data functionality - temporarily disabled
1262
+ /*
1263
+ function setupHistoricalView(modelType) {
1264
+ const loadButton = document.getElementById(`${modelType}-load-historical`);
1265
+ const dateSelect = document.getElementById(`${modelType}-date-select`);
1266
+ const historicalRows = document.getElementById(`${modelType}-historical-rows`);
1267
+ const loadingSpinner = document.getElementById(`${modelType}-loading-spinner`);
1268
+ const historicalIndicator = document.getElementById(`${modelType}-historical-indicator`);
1269
+ const historicalDate = document.getElementById(`${modelType}-historical-date`);
1270
+ const timelineMarker = document.getElementById(`${modelType}-timeline-marker`);
1271
+ const timelineProgress = document.getElementById(`${modelType}-timeline-progress`);
1272
+
1273
+ if (!loadButton || !dateSelect || !historicalRows) return;
1274
+
1275
+ loadButton.addEventListener('click', function() {
1276
+ const selectedDate = dateSelect.value;
1277
+ if (!selectedDate) return;
1278
+
1279
+ // Show loading state
1280
+ loadingSpinner.classList.add('loading');
1281
+
1282
+ // Fetch historical data
1283
+ fetch(`/api/historical-leaderboard/${modelType}?date=${selectedDate}`)
1284
+ .then(response => {
1285
+ if (!response.ok) {
1286
+ throw new Error('Network response was not ok');
1287
+ }
1288
+ return response.json();
1289
+ })
1290
+ .then(data => {
1291
+ // Update historical indicator
1292
+ historicalIndicator.classList.add('active');
1293
+ historicalDate.textContent = data.date;
1294
+
1295
+ // Clear existing rows
1296
+ historicalRows.innerHTML = '';
1297
+
1298
+ if (data.leaderboard && data.leaderboard.length > 0) {
1299
+ // Add new rows
1300
+ data.leaderboard.forEach(model => {
1301
+ const row = document.createElement('div');
1302
+ row.className = `leaderboard-row ${model.tier || ''}`;
1303
+
1304
+ row.innerHTML = `
1305
+ <div class="rank">#${model.rank}</div>
1306
+ <div class="model-name">
1307
+ ${model.model_url ?
1308
+ `<a href="${model.model_url}" target="_blank" class="model-name-link">${model.name}</a>` :
1309
+ model.name
1310
+ }
1311
+ <div class="license-icon">
1312
+ <img src="${model.is_open ? '/static/open.svg' : '/static/closed.svg'}" alt="${model.is_open ? 'Open' : 'Proprietary'}">
1313
+ <span class="tooltip">${model.is_open ? 'Open model' : 'Proprietary model'}</span>
1314
+ </div>
1315
+ </div>
1316
+ <div class="win-rate">${model.win_rate}</div>
1317
+ <div class="total-votes">${model.total_votes}</div>
1318
+ <div class="elo-score">${model.elo}</div>
1319
+ `;
1320
+
1321
+ historicalRows.appendChild(row);
1322
+ });
1323
+ } else {
1324
+ // Show no data message
1325
+ historicalRows.innerHTML = `
1326
+ <div class="no-data">
1327
+ <p>No data available for this date.</p>
1328
+ </div>
1329
+ `;
1330
+ }
1331
+
1332
+ // Update timeline marker position based on selected date
1333
+ updateTimelinePosition(modelType, selectedDate);
1334
+ })
1335
+ .catch(error => {
1336
+ console.error('Error fetching historical data:', error);
1337
+ historicalRows.innerHTML = `
1338
+ <div class="no-data">
1339
+ <p>Error loading data. Please try again.</p>
1340
+ </div>
1341
+ `;
1342
+ })
1343
+ .finally(() => {
1344
+ // Hide loading state
1345
+ loadingSpinner.classList.remove('loading');
1346
+ });
1347
+ });
1348
+
1349
+ // Update the timeline marker position
1350
+ function updateTimelinePosition(modelType, selectedDate) {
1351
+ const timeline = document.querySelector(`#${modelType}-timeline-container .timeline-track`);
1352
+ if (!timeline || !timelineMarker || !timelineProgress) return;
1353
+
1354
+ // Get all the dates
1355
+ const options = Array.from(dateSelect.options);
1356
+ const dateValues = options.map(option => option.value);
1357
+ const selectedIndex = dateValues.indexOf(selectedDate);
1358
+
1359
+ if (selectedIndex >= 0 && dateValues.length > 1) {
1360
+ // Calculate percentage position (0 to 100)
1361
+ const position = (selectedIndex / (dateValues.length - 1)) * 100;
1362
+
1363
+ // Update marker and progress
1364
+ timelineMarker.style.left = `${position}%`;
1365
+ timelineProgress.style.width = `${position}%`;
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ // Setup historical view for both model types
1371
+ setupHistoricalView('tts');
1372
+ setupHistoricalView('conversational');
1373
+ */
1374
+
1375
+ // Final positioning after all DOM operations are complete
1376
+ setTimeout(positionSliders, 100);
1377
+
1378
+ // Reposition sliders on window resize
1379
+ window.addEventListener('resize', function() {
1380
+ positionSliders();
1381
+ });
1382
+
1383
+ // Add to the end of the script section
1384
+ document.getElementById('visibility-toggle')?.addEventListener('change', function() {
1385
+ // Send request to toggle visibility
1386
+ fetch('/api/toggle-leaderboard-visibility', {
1387
+ method: 'POST',
1388
+ headers: {
1389
+ 'Content-Type': 'application/json',
1390
+ },
1391
+ credentials: 'same-origin'
1392
+ })
1393
+ .then(response => response.json())
1394
+ .then(data => {
1395
+ if (data.success) {
1396
+ // Use the toast function from base.html
1397
+ openToast(data.message, 'success');
1398
+ } else {
1399
+ openToast(data.error || 'Failed to update visibility', 'error');
1400
+ // Revert the toggle state if there was an error
1401
+ this.checked = !this.checked;
1402
+ }
1403
+ })
1404
+ .catch(error => {
1405
+ console.error('Error:', error);
1406
+ openToast('Failed to update visibility', 'error');
1407
+ // Revert the toggle state if there was an error
1408
+ this.checked = !this.checked;
1409
+ });
1410
+ });
1411
+ });
1412
+ </script>
1413
+ {% endblock %}
templates/turnstile.html ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Verification Required - TTS Arena</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
10
+ <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
11
+ <style>
12
+ :root {
13
+ --primary-color: #5046e5;
14
+ --secondary-color: #f0f0f0;
15
+ --text-color: #333;
16
+ --light-gray: #f5f5f5;
17
+ --border-color: #e0e0e0;
18
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
19
+ --radius: 8px;
20
+ }
21
+
22
+ * {
23
+ margin: 0;
24
+ padding: 0;
25
+ box-sizing: border-box;
26
+ font-family: 'Inter', sans-serif;
27
+ }
28
+
29
+ body {
30
+ color: var(--text-color);
31
+ display: flex;
32
+ min-height: 100vh;
33
+ justify-content: center;
34
+ align-items: center;
35
+ background-color: var(--light-gray);
36
+ }
37
+
38
+ .verification-container {
39
+ background-color: white;
40
+ border-radius: var(--radius);
41
+ box-shadow: var(--shadow);
42
+ padding: 32px;
43
+ width: 100%;
44
+ max-width: 450px;
45
+ text-align: center;
46
+ }
47
+
48
+ .logo {
49
+ font-size: 24px;
50
+ font-weight: 700;
51
+ margin-bottom: 24px;
52
+ color: var(--primary-color);
53
+ }
54
+
55
+ h1 {
56
+ font-size: 20px;
57
+ margin-bottom: 16px;
58
+ color: var(--text-color);
59
+ }
60
+
61
+ p {
62
+ margin-bottom: 24px;
63
+ color: #666;
64
+ line-height: 1.5;
65
+ }
66
+
67
+ .turnstile-container {
68
+ display: flex;
69
+ justify-content: center;
70
+ margin-bottom: 24px;
71
+ }
72
+
73
+ .btn {
74
+ background-color: var(--primary-color);
75
+ color: white;
76
+ border: none;
77
+ border-radius: var(--radius);
78
+ padding: 12px 24px;
79
+ font-weight: 500;
80
+ cursor: pointer;
81
+ font-size: 1rem;
82
+ transition: background-color 0.2s;
83
+ width: 100%;
84
+ }
85
+
86
+ .btn:hover {
87
+ background-color: #4038c7;
88
+ }
89
+
90
+ .btn:disabled {
91
+ background-color: #a8a4e0;
92
+ cursor: not-allowed;
93
+ }
94
+
95
+ .loader {
96
+ display: none;
97
+ width: 24px;
98
+ height: 24px;
99
+ border: 3px solid rgba(255, 255, 255, 0.3);
100
+ border-radius: 50%;
101
+ border-top-color: white;
102
+ animation: spin 1s ease infinite;
103
+ margin: 0 auto;
104
+ }
105
+
106
+ @keyframes spin {
107
+ to {
108
+ transform: rotate(360deg);
109
+ }
110
+ }
111
+
112
+ .btn.loading .btn-text {
113
+ display: none;
114
+ font-size: 1rem;
115
+ }
116
+
117
+ .btn.loading .loader {
118
+ display: inline-block;
119
+ }
120
+
121
+ .status-message {
122
+ margin-top: 16px;
123
+ font-size: 14px;
124
+ color: #666;
125
+ display: none;
126
+ }
127
+
128
+ /* Dark mode styles */
129
+ @media (prefers-color-scheme: dark) {
130
+ :root {
131
+ --primary-color: #6c63ff;
132
+ --secondary-color: #2d2b38;
133
+ --text-color: #e0e0e0;
134
+ --light-gray: #1e1e24;
135
+ --border-color: #3a3a45;
136
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
137
+ }
138
+
139
+ body {
140
+ background-color: #121218;
141
+ }
142
+
143
+ .verification-container {
144
+ background-color: var(--light-gray);
145
+ border: 1px solid var(--border-color);
146
+ }
147
+
148
+ p {
149
+ color: #aaa;
150
+ }
151
+
152
+ .status-message {
153
+ color: #aaa;
154
+ }
155
+ }
156
+ </style>
157
+ </head>
158
+ <body>
159
+ <div class="verification-container">
160
+ <div class="logo">TTS Arena</div>
161
+ <h1>Verification Required</h1>
162
+ <p>Please complete the verification below to access TTS Arena.</p>
163
+
164
+ <div id="turnstile-form">
165
+ <div class="turnstile-container">
166
+ <div id="cf-turnstile" class="cf-turnstile"></div>
167
+ </div>
168
+ <button type="button" class="btn" id="submit-btn" disabled onclick="submitVerification()">
169
+ <span class="btn-text">Continue to TTS Arena</span>
170
+ <span class="loader"></span>
171
+ </button>
172
+ <div id="status-message" class="status-message"></div>
173
+ </div>
174
+ </div>
175
+
176
+ <script>
177
+ // Store the token and redirect URL
178
+ let turnstileToken = '';
179
+ const redirectUrl = '{{ redirect_url }}';
180
+ // Make sure the redirect URL uses HTTPS for HuggingFace Spaces
181
+ const secureRedirectUrl = redirectUrl.replace(/^http:\/\//i, 'https://');
182
+ const verifyEndpoint = '{{ url_for("verify_turnstile") }}';
183
+ const statusMessage = document.getElementById('status-message');
184
+ const submitButton = document.getElementById('submit-btn');
185
+
186
+ // Function to enable the button when verification is successful
187
+ function onTurnstileSuccess(token) {
188
+ turnstileToken = token;
189
+ submitButton.disabled = false;
190
+ console.log("Turnstile verification successful");
191
+ }
192
+
193
+ // Function to handle verification errors
194
+ function onTurnstileError(error) {
195
+ console.error("Turnstile error:", error);
196
+ statusMessage.textContent = "Verification error. Please try again.";
197
+ statusMessage.style.display = "block";
198
+ }
199
+
200
+ // Function to submit the verification via AJAX to handle iframe issues
201
+ function submitVerification() {
202
+ if (!turnstileToken) {
203
+ statusMessage.textContent = "Please complete the verification first.";
204
+ statusMessage.style.display = "block";
205
+ return;
206
+ }
207
+
208
+ // Show loading state
209
+ submitButton.classList.add('loading');
210
+ submitButton.disabled = true;
211
+
212
+ // Create form data
213
+ const formData = new FormData();
214
+ formData.append('cf-turnstile-response', turnstileToken);
215
+ formData.append('redirect_url', secureRedirectUrl);
216
+
217
+ // Send verification request
218
+ fetch(verifyEndpoint, {
219
+ method: 'POST',
220
+ body: formData,
221
+ credentials: 'same-origin', // Important for cookies
222
+ headers: {
223
+ 'X-Requested-With': 'XMLHttpRequest',
224
+ 'Accept': 'application/json'
225
+ }
226
+ })
227
+ .then(response => {
228
+ if (response.redirected) {
229
+ // Handle redirect from the response
230
+ window.location.href = response.url;
231
+ } else {
232
+ return response.json().then(data => {
233
+ if (data.success) {
234
+ // If we got a JSON success response, redirect
235
+ window.location.href = secureRedirectUrl;
236
+ } else {
237
+ throw new Error("Verification failed");
238
+ }
239
+ });
240
+ }
241
+ })
242
+ .catch(error => {
243
+ console.error("Verification error:", error);
244
+ statusMessage.textContent = "Verification failed. Please try again.";
245
+ statusMessage.style.display = "block";
246
+ submitButton.classList.remove('loading');
247
+ submitButton.disabled = false;
248
+
249
+ // Reset Turnstile if something goes wrong
250
+ if (typeof turnstile !== 'undefined') {
251
+ turnstile.reset('#cf-turnstile');
252
+ }
253
+ });
254
+ }
255
+
256
+ // Initialize Turnstile when the page is loaded
257
+ document.addEventListener('DOMContentLoaded', function() {
258
+ // Check for Turnstile script readiness
259
+ function waitForTurnstile() {
260
+ if (typeof turnstile !== 'undefined') {
261
+ // Render the Turnstile widget
262
+ turnstile.render('#cf-turnstile', {
263
+ sitekey: '{{ turnstile_site_key }}',
264
+ callback: onTurnstileSuccess,
265
+ 'error-callback': onTurnstileError
266
+ });
267
+ } else {
268
+ // If not ready yet, wait and try again
269
+ setTimeout(waitForTurnstile, 100);
270
+ }
271
+ }
272
+
273
+ waitForTurnstile();
274
+ });
275
+ </script>
276
+ </body>
277
+ </html>
tts.old.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TODO: V2 of TTS Router
2
+ # Currently just use current TTS router.
3
+ from gradio_client import Client
4
+ import os
5
+ from dotenv import load_dotenv
6
+ import fal_client
7
+ import requests
8
+ import time
9
+ import io
10
+ from pyht import Client as PyhtClient
11
+ from pyht.client import TTSOptions
12
+
13
+ load_dotenv()
14
+
15
+ try:
16
+ client = Client("TTS-AGI/tts-router", hf_token=os.getenv("HF_TOKEN"))
17
+ except Exception as e:
18
+ print(f"Error initializing client: {e}")
19
+ client = None
20
+
21
+ model_mapping = {
22
+ "eleven-multilingual-v2": "eleven",
23
+ "playht-2.0": "playht",
24
+ "styletts2": "styletts2",
25
+ "kokoro-v1": "kokorov1",
26
+ "cosyvoice-2.0": "cosyvoice",
27
+ "playht-3.0-mini": "playht3",
28
+ "papla-p1": "papla",
29
+ "hume-octave": "hume",
30
+ }
31
+
32
+
33
+ def predict_csm(script):
34
+ result = fal_client.subscribe(
35
+ "fal-ai/csm-1b",
36
+ arguments={
37
+ # "scene": [{
38
+ # "text": "Hey how are you doing.",
39
+ # "speaker_id": 0
40
+ # }, {
41
+ # "text": "Pretty good, pretty good.",
42
+ # "speaker_id": 1
43
+ # }, {
44
+ # "text": "I'm great, so happy to be speaking to you.",
45
+ # "speaker_id": 0
46
+ # }]
47
+ "scene": script
48
+ },
49
+ with_logs=True,
50
+ )
51
+ return requests.get(result["audio"]["url"]).content
52
+
53
+
54
+ def predict_playdialog(script):
55
+ # Initialize the PyHT client
56
+ pyht_client = PyhtClient(
57
+ user_id=os.getenv("PLAY_USERID"),
58
+ api_key=os.getenv("PLAY_SECRETKEY"),
59
+ )
60
+
61
+ # Define the voices
62
+ voice_1 = "s3://voice-cloning-zero-shot/baf1ef41-36b6-428c-9bdf-50ba54682bd8/original/manifest.json"
63
+ voice_2 = "s3://voice-cloning-zero-shot/e040bd1b-f190-4bdb-83f0-75ef85b18f84/original/manifest.json"
64
+
65
+ # Convert script format from CSM to PlayDialog format
66
+ if isinstance(script, list):
67
+ # Process script in CSM format (list of dictionaries)
68
+ text = ""
69
+ for turn in script:
70
+ speaker_id = turn.get("speaker_id", 0)
71
+ prefix = "Host 1:" if speaker_id == 0 else "Host 2:"
72
+ text += f"{prefix} {turn['text']}\n"
73
+ else:
74
+ # If it's already a string, use as is
75
+ text = script
76
+
77
+ # Set up TTSOptions
78
+ options = TTSOptions(
79
+ voice=voice_1, voice_2=voice_2, turn_prefix="Host 1:", turn_prefix_2="Host 2:"
80
+ )
81
+
82
+ # Generate audio using PlayDialog
83
+ audio_chunks = []
84
+ for chunk in pyht_client.tts(text, options, voice_engine="PlayDialog"):
85
+ audio_chunks.append(chunk)
86
+
87
+ # Combine all chunks into a single audio file
88
+ return b"".join(audio_chunks)
89
+
90
+
91
+ def predict_tts(text, model):
92
+ global client
93
+ # Exceptions: special models that shouldn't be passed to the router
94
+ if model == "csm-1b":
95
+ return predict_csm(text)
96
+ elif model == "playdialog-1.0":
97
+ return predict_playdialog(text)
98
+
99
+ if not model in model_mapping:
100
+ raise ValueError(f"Model {model} not found")
101
+ result = client.predict(
102
+ text=text, model=model_mapping[model], api_name="/synthesize"
103
+ ) # returns path to audio file
104
+ return result
105
+
106
+
107
+ if __name__ == "__main__":
108
+ print("Predicting PlayDialog")
109
+ print(
110
+ predict_playdialog(
111
+ [
112
+ {"text": "Hey how are you doing.", "speaker_id": 0},
113
+ {"text": "Pretty good, pretty good.", "speaker_id": 1},
114
+ {"text": "I'm great, so happy to be speaking to you.", "speaker_id": 0},
115
+ ]
116
+ )
117
+ )
tts.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TODO: V2 of TTS Router
2
+ # Currently just use current TTS router.
3
+ import os
4
+ import json
5
+ from dotenv import load_dotenv
6
+ import fal_client
7
+ import requests
8
+ import time
9
+ import io
10
+ from pyht import Client as PyhtClient
11
+ from pyht.client import TTSOptions
12
+ import base64
13
+ import tempfile
14
+ import random
15
+
16
+ load_dotenv()
17
+
18
+ ZEROGPU_TOKENS = os.getenv("ZEROGPU_TOKENS", "").split(",")
19
+
20
+
21
+ def get_zerogpu_token():
22
+ return random.choice(ZEROGPU_TOKENS)
23
+
24
+
25
+ model_mapping = {
26
+ "eleven-multilingual-v2": {
27
+ "provider": "elevenlabs",
28
+ "model": "eleven_multilingual_v2",
29
+ },
30
+ "eleven-turbo-v2.5": {
31
+ "provider": "elevenlabs",
32
+ "model": "eleven_turbo_v2_5",
33
+ },
34
+ "eleven-flash-v2.5": {
35
+ "provider": "elevenlabs",
36
+ "model": "eleven_flash_v2_5",
37
+ },
38
+ "cartesia-sonic-2": {
39
+ "provider": "cartesia",
40
+ "model": "sonic-2",
41
+ },
42
+ "spark-tts": {
43
+ "provider": "spark",
44
+ "model": "spark-tts",
45
+ },
46
+ "playht-2.0": {
47
+ "provider": "playht",
48
+ "model": "PlayHT2.0",
49
+ },
50
+ "styletts2": {
51
+ "provider": "styletts",
52
+ "model": "styletts2",
53
+ },
54
+ "kokoro-v1": {
55
+ "provider": "kokoro",
56
+ "model": "kokoro_v1",
57
+ },
58
+ "cosyvoice-2.0": {
59
+ "provider": "cosyvoice",
60
+ "model": "cosyvoice_2_0",
61
+ },
62
+ "papla-p1": {
63
+ "provider": "papla",
64
+ "model": "papla_p1",
65
+ },
66
+ "hume-octave": {
67
+ "provider": "hume",
68
+ "model": "octave",
69
+ },
70
+ "megatts3": {
71
+ "provider": "megatts3",
72
+ "model": "megatts3",
73
+ },
74
+ }
75
+
76
+ url = "https://tts-agi-tts-router-v2.hf.space/tts"
77
+ headers = {
78
+ "accept": "application/json",
79
+ "Content-Type": "application/json",
80
+ "Authorization": f'Bearer {os.getenv("HF_TOKEN")}',
81
+ }
82
+ data = {"text": "string", "provider": "string", "model": "string"}
83
+
84
+
85
+ def predict_csm(script):
86
+ result = fal_client.subscribe(
87
+ "fal-ai/csm-1b",
88
+ arguments={
89
+ # "scene": [{
90
+ # "text": "Hey how are you doing.",
91
+ # "speaker_id": 0
92
+ # }, {
93
+ # "text": "Pretty good, pretty good.",
94
+ # "speaker_id": 1
95
+ # }, {
96
+ # "text": "I'm great, so happy to be speaking to you.",
97
+ # "speaker_id": 0
98
+ # }]
99
+ "scene": script
100
+ },
101
+ with_logs=True,
102
+ )
103
+ return requests.get(result["audio"]["url"]).content
104
+
105
+
106
+ def predict_playdialog(script):
107
+ # Initialize the PyHT client
108
+ pyht_client = PyhtClient(
109
+ user_id=os.getenv("PLAY_USERID"),
110
+ api_key=os.getenv("PLAY_SECRETKEY"),
111
+ )
112
+
113
+ # Define the voices
114
+ voice_1 = "s3://voice-cloning-zero-shot/baf1ef41-36b6-428c-9bdf-50ba54682bd8/original/manifest.json"
115
+ voice_2 = "s3://voice-cloning-zero-shot/e040bd1b-f190-4bdb-83f0-75ef85b18f84/original/manifest.json"
116
+
117
+ # Convert script format from CSM to PlayDialog format
118
+ if isinstance(script, list):
119
+ # Process script in CSM format (list of dictionaries)
120
+ text = ""
121
+ for turn in script:
122
+ speaker_id = turn.get("speaker_id", 0)
123
+ prefix = "Host 1:" if speaker_id == 0 else "Host 2:"
124
+ text += f"{prefix} {turn['text']}\n"
125
+ else:
126
+ # If it's already a string, use as is
127
+ text = script
128
+
129
+ # Set up TTSOptions
130
+ options = TTSOptions(
131
+ voice=voice_1, voice_2=voice_2, turn_prefix="Host 1:", turn_prefix_2="Host 2:"
132
+ )
133
+
134
+ # Generate audio using PlayDialog
135
+ audio_chunks = []
136
+ for chunk in pyht_client.tts(text, options, voice_engine="PlayDialog"):
137
+ audio_chunks.append(chunk)
138
+
139
+ # Combine all chunks into a single audio file
140
+ return b"".join(audio_chunks)
141
+
142
+
143
+ def predict_dia(script):
144
+ # Convert script to the required format for Dia
145
+ if isinstance(script, list):
146
+ # Convert from list of dictionaries to formatted string
147
+ formatted_text = ""
148
+ for turn in script:
149
+ speaker_id = turn.get("speaker_id", 0)
150
+ speaker_tag = "[S1]" if speaker_id == 0 else "[S2]"
151
+ text = turn.get("text", "").strip().replace("[S1]", "").replace("[S2]", "")
152
+ formatted_text += f"{speaker_tag} {text} "
153
+ text = formatted_text.strip()
154
+ else:
155
+ # If it's already a string, use as is
156
+ text = script
157
+ print(text)
158
+ # Make a POST request to initiate the dialogue generation
159
+ headers = {
160
+ # "Content-Type": "application/json",
161
+ "Authorization": f"Bearer {get_zerogpu_token()}"
162
+ }
163
+
164
+ response = requests.post(
165
+ "https://mrfakename-dia-1-6b.hf.space/gradio_api/call/generate_dialogue",
166
+ headers=headers,
167
+ json={"data": [text]},
168
+ )
169
+
170
+ # Extract the event ID from the response
171
+ event_id = response.json()["event_id"]
172
+
173
+ # Make a streaming request to get the generated dialogue
174
+ stream_url = f"https://mrfakename-dia-1-6b.hf.space/gradio_api/call/generate_dialogue/{event_id}"
175
+
176
+ # Use a streaming request to get the audio data
177
+ with requests.get(stream_url, headers=headers, stream=True) as stream_response:
178
+ # Process the streaming response
179
+ for line in stream_response.iter_lines():
180
+ if line:
181
+ if line.startswith(b"data: ") and not line.startswith(b"data: null"):
182
+ audio_data = line[6:]
183
+ return requests.get(json.loads(audio_data)[0]["url"]).content
184
+
185
+
186
+ def predict_tts(text, model):
187
+ global client
188
+ print(f"Predicting TTS for {model}")
189
+ # Exceptions: special models that shouldn't be passed to the router
190
+ if model == "csm-1b":
191
+ return predict_csm(text)
192
+ elif model == "playdialog-1.0":
193
+ return predict_playdialog(text)
194
+ elif model == "dia-1.6b":
195
+ return predict_dia(text)
196
+
197
+ if not model in model_mapping:
198
+ raise ValueError(f"Model {model} not found")
199
+
200
+ result = requests.post(
201
+ url,
202
+ headers=headers,
203
+ data=json.dumps(
204
+ {
205
+ "text": text,
206
+ "provider": model_mapping[model]["provider"],
207
+ "model": model_mapping[model]["model"],
208
+ }
209
+ ),
210
+ )
211
+
212
+ response_json = result.json()
213
+
214
+ audio_data = response_json["audio_data"] # base64 encoded audio data
215
+ extension = response_json["extension"]
216
+ # Decode the base64 audio data
217
+ audio_bytes = base64.b64decode(audio_data)
218
+
219
+ # Create a temporary file to store the audio data
220
+ with tempfile.NamedTemporaryFile(delete=False, suffix=f".{extension}") as temp_file:
221
+ temp_file.write(audio_bytes)
222
+ temp_path = temp_file.name
223
+
224
+ return temp_path
225
+
226
+
227
+ if __name__ == "__main__":
228
+ print(
229
+ predict_dia(
230
+ [
231
+ {"text": "Hello, how are you?", "speaker_id": 0},
232
+ {"text": "I'm great, thank you!", "speaker_id": 1},
233
+ ]
234
+ )
235
+ )
236
+ # print("Predicting PlayDialog")
237
+ # print(
238
+ # predict_playdialog(
239
+ # [
240
+ # {"text": "Hey how are you doing.", "speaker_id": 0},
241
+ # {"text": "Pretty good, pretty good.", "speaker_id": 1},
242
+ # {"text": "I'm great, so happy to be speaking to you.", "speaker_id": 0},
243
+ # ]
244
+ # )
245
+ # )