This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .dockerignore +0 -24
  2. .env.development.local.example +0 -20
  3. .env.example +12 -7
  4. .gitattributes +35 -5
  5. .github/workflows/ci.yml +0 -52
  6. .github/workflows/deploy-docs.yml +0 -62
  7. .github/workflows/deploy-space.yml +0 -119
  8. .github/workflows/sync-to-hf.yaml +43 -0
  9. .gitignore +49 -43
  10. .prettierignore +0 -20
  11. .prettierrc.json +0 -9
  12. COPYING +15 -0
  13. Dockerfile +0 -115
  14. Dockerfile.docs +0 -46
  15. LICENSE +15 -197
  16. LICENSE.APACHE +201 -0
  17. LICENSE.MIT +21 -0
  18. README.md +9 -9
  19. SPACES_DOCS_README.md +0 -19
  20. TURNSTILE.md +58 -0
  21. admin.py +891 -0
  22. app.py +1694 -0
  23. apps/docs/.gitignore +0 -26
  24. apps/docs/app/[[...slug]]/page.tsx +0 -67
  25. apps/docs/app/api/search/route.ts +0 -7
  26. apps/docs/app/global.css +0 -12
  27. apps/docs/app/layout.tsx +0 -37
  28. apps/docs/app/llms-full.txt/route.ts +0 -10
  29. apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts +0 -26
  30. apps/docs/app/llms.txt/route.ts +0 -8
  31. apps/docs/app/og/docs/[...slug]/route.tsx +0 -35
  32. apps/docs/components/mdx.tsx +0 -15
  33. apps/docs/content/docs/api.mdx +0 -53
  34. apps/docs/content/docs/development.mdx +0 -97
  35. apps/docs/content/docs/index.mdx +0 -46
  36. apps/docs/content/docs/meta.json +0 -11
  37. apps/docs/content/docs/ranking.mdx +0 -56
  38. apps/docs/content/docs/submit-a-model.mdx +0 -38
  39. apps/docs/content/docs/voting.mdx +0 -45
  40. apps/docs/lib/cn.ts +0 -1
  41. apps/docs/lib/layout.shared.tsx +0 -24
  42. apps/docs/lib/shared.ts +0 -14
  43. apps/docs/lib/source.ts +0 -37
  44. apps/docs/next.config.mjs +0 -25
  45. apps/docs/package.json +0 -33
  46. apps/docs/postcss.config.mjs +0 -7
  47. apps/docs/proxy.ts +0 -29
  48. apps/docs/public/.gitkeep +0 -1
  49. apps/docs/source.config.ts +0 -23
  50. apps/docs/tsconfig.json +0 -35
.dockerignore DELETED
@@ -1,24 +0,0 @@
1
- # deps & build output (installed/built inside the image)
2
- node_modules
3
- **/node_modules
4
- .next
5
- **/.next
6
- dist
7
- **/dist
8
- out
9
- **/out
10
-
11
- # vcs & local
12
- .git
13
- .github
14
- *.tsbuildinfo
15
-
16
- # env & secrets — never bake into images
17
- .env
18
- .env.*
19
- **/.env
20
- **/.env.*
21
-
22
- # misc
23
- .DS_Store
24
- *.log
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.env.development.local.example DELETED
@@ -1,20 +0,0 @@
1
- # App secrets — copy to `.env.development.local` and fill in. Gitignored.
2
-
3
- # Session cookie signing secret (openssl rand -hex 32). Required for auth.
4
- SESSION_SECRET=
5
-
6
- # Hugging Face OAuth (huggingface.co/settings/applications)
7
- # Redirect URL: ${APP_URL}/api/auth/callback
8
- HF_OAUTH_CLIENT_ID=
9
- HF_OAUTH_CLIENT_SECRET=
10
-
11
- # Admin usernames (comma-separated, lowercased). Grants /admin access.
12
- ADMIN_USERS=mrfakename
13
-
14
- # Provider API keys — each provider activates when its key(s) are set.
15
- ELEVENLABS_API_KEY=
16
- MINIMAX_API_KEY=
17
- MINIMAX_GROUP_ID=
18
-
19
- # Optional private provider plugin packages (comma-separated)
20
- PROVIDER_PLUGINS=
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.env.example CHANGED
@@ -1,10 +1,15 @@
1
- # Compose-level config — copy to `.env` and adjust. Safe dev defaults shown.
2
- # (App secrets go in .env.development.local, not here.)
 
 
3
 
4
- WEB_PORT=3000
5
- ROUTER_PORT=8080
 
6
 
7
- APP_URL=http://localhost:3000
 
 
 
8
 
9
- # Shared bearer key between web and router (empty = open local dev).
10
- ROUTER_API_KEY=
 
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
 
.gitattributes CHANGED
@@ -1,5 +1,35 @@
1
- # Bun's text lockfile — keep it diffable and never treated as binary (HF Spaces
2
- # reject binary files outside Xet/LFS).
3
- bun.lock text eol=lf linguist-generated=true
4
- # Large prompt corpus is stored via Git LFS.
5
- apps/web/data/combined_prompts.txt filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.github/workflows/ci.yml DELETED
@@ -1,52 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
- workflow_dispatch:
9
-
10
- # Cancel superseded runs on the same ref to save CI minutes.
11
- concurrency:
12
- group: ci-${{ github.workflow }}-${{ github.ref }}
13
- cancel-in-progress: true
14
-
15
- permissions:
16
- contents: read
17
-
18
- jobs:
19
- quality:
20
- name: Lint, typecheck, test & build
21
- runs-on: ubuntu-latest
22
- timeout-minutes: 15
23
-
24
- steps:
25
- - name: Checkout
26
- uses: actions/checkout@v4
27
-
28
- - name: Setup Bun
29
- uses: oven-sh/setup-bun@v2
30
- with:
31
- # Pin to the version that writes the lockfile (the Space image uses
32
- # bun 1.1.42 too). "latest" drifted and failed to resolve the newer
33
- # apps/docs deps from a 1.1.42-written lockfile.
34
- bun-version: 1.1.42
35
-
36
- - name: Install dependencies
37
- run: bun install
38
-
39
- - name: Format check
40
- run: bun run format:check
41
-
42
- - name: Lint
43
- run: bun run lint
44
-
45
- - name: Typecheck
46
- run: bun run typecheck
47
-
48
- - name: Test
49
- run: bun test
50
-
51
- - name: Build
52
- run: bun run build
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/workflows/deploy-docs.yml DELETED
@@ -1,62 +0,0 @@
1
- name: Deploy Docs to Hugging Face Space
2
-
3
- # Pushes apps/docs to the TTS-AGI/docs Space (served at https://docs.ttsarena.org).
4
- # Runs after CI passes on main. The Space builds the Dockerfile itself.
5
- on:
6
- workflow_run:
7
- workflows: ["CI"]
8
- types: [completed]
9
- branches: [main]
10
- workflow_dispatch:
11
-
12
- concurrency:
13
- group: deploy-docs
14
- cancel-in-progress: false
15
-
16
- permissions:
17
- contents: read
18
-
19
- env:
20
- HF_OWNER: TTS-AGI
21
- HF_SPACE: docs
22
-
23
- jobs:
24
- deploy:
25
- name: Sync docs to Space
26
- runs-on: ubuntu-latest
27
- if: >
28
- github.event_name == 'workflow_dispatch' ||
29
- github.event.workflow_run.conclusion == 'success'
30
- steps:
31
- - name: Checkout
32
- uses: actions/checkout@v4
33
-
34
- - name: Push to the docs Space
35
- env:
36
- HF_TOKEN: ${{ secrets.HF_TOKEN }}
37
- run: |
38
- git config --global user.email "actions@github.com"
39
- git config --global user.name "GitHub Actions"
40
-
41
- REMOTE="https://${HF_OWNER}:${HF_TOKEN}@huggingface.co/spaces/${HF_OWNER}/${HF_SPACE}"
42
-
43
- # The docs app is self-contained (no @ttsa/* workspace deps), and its
44
- # Dockerfile builds apps/docs in isolation. So the Space only needs the
45
- # apps/docs tree + the Dockerfile + README — nothing else from the
46
- # monorepo (no LFS corpus, no sibling apps, no native deps).
47
- DEST=/tmp/docs-space
48
- rm -rf "$DEST" && mkdir -p "$DEST/apps/docs"
49
- rsync -a \
50
- --exclude='.git' --exclude='node_modules' --exclude='.next' \
51
- --exclude='.source' \
52
- apps/docs/ "$DEST/apps/docs/"
53
- cp Dockerfile.docs "$DEST/Dockerfile"
54
- cp SPACES_DOCS_README.md "$DEST/README.md"
55
-
56
- # Single fresh commit (orphan history). Plain text — no LFS.
57
- cd "$DEST"
58
- git init -q
59
- git checkout -q -b main
60
- git add -A
61
- git commit -q -m "Deploy docs from github.com/${{ github.repository }}@${GITHUB_SHA::7}"
62
- git push -f "$REMOTE" main
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/workflows/deploy-space.yml DELETED
@@ -1,119 +0,0 @@
1
- name: Deploy to Hugging Face Space
2
-
3
- # Pushes the repo to the HF Space and syncs Space secrets. Runs after CI passes
4
- # on main. The Space README and Dockerfile are derived during the sync so the
5
- # GitHub repo keeps its own.
6
- on:
7
- workflow_run:
8
- workflows: ["CI"]
9
- types: [completed]
10
- branches: [main]
11
- workflow_dispatch:
12
-
13
- concurrency:
14
- group: deploy-space
15
- cancel-in-progress: false
16
-
17
- permissions:
18
- contents: read
19
-
20
- env:
21
- HF_OWNER: TTS-AGI
22
- HF_SPACE: TTS-Arena-V2
23
-
24
- jobs:
25
- deploy:
26
- name: Sync to Space
27
- runs-on: ubuntu-latest
28
- # Only deploy when the triggering CI run succeeded (or on manual dispatch).
29
- if: >
30
- github.event_name == 'workflow_dispatch' ||
31
- github.event.workflow_run.conclusion == 'success'
32
- steps:
33
- - name: Checkout
34
- uses: actions/checkout@v4
35
- with:
36
- lfs: true # pull the prompt corpus content, not just LFS pointers
37
-
38
- - name: Sync deployment secrets to the Space
39
- env:
40
- HF_TOKEN: ${{ secrets.HF_TOKEN }}
41
- # Pass ALL repo secrets as one JSON blob so provider names (public or
42
- # private) never appear in this public workflow. set-secrets.sh syncs
43
- # every key to the Space except the infra-only ones it excludes.
44
- ALL_SECRETS: ${{ toJSON(secrets) }}
45
- run: |
46
- chmod +x deploy/space/set-secrets.sh
47
- deploy/space/set-secrets.sh "${HF_OWNER}/${HF_SPACE}"
48
-
49
- - name: Push to the Space
50
- env:
51
- HF_TOKEN: ${{ secrets.HF_TOKEN }}
52
- PRIVATE_PROVIDERS_TOKEN: ${{ secrets.PRIVATE_PROVIDERS_TOKEN }}
53
- run: |
54
- git config --global user.email "actions@github.com"
55
- git config --global user.name "GitHub Actions"
56
-
57
- REMOTE="https://${HF_OWNER}:${HF_TOKEN}@huggingface.co/spaces/${HF_OWNER}/${HF_SPACE}"
58
-
59
- # Build a Space-flavored copy of the repo (no .git). The private
60
- # providers are NOT vendored here — the Space's Docker build clones
61
- # them itself using the PRIVATE_PROVIDERS_TOKEN Space secret (mounted
62
- # as a build secret), so the private source never enters the Space
63
- # repo and the Space can be made public safely.
64
- rsync -a --exclude='.git' --exclude='node_modules' ./ /tmp/space/
65
- # Space README + Dockerfile take precedence; GitHub's stay in the repo.
66
- mv /tmp/space/SPACES_README.md /tmp/space/README.md
67
- mv /tmp/space/Dockerfile.space /tmp/space/Dockerfile
68
-
69
- # Cache-bust the private-providers clone layer. That RUN clones a repo
70
- # behind a fixed command, so Docker reuses a stale layer and the Space
71
- # keeps an OLD snapshot of the private repo forever (new private models
72
- # never appear). Stamp the private repo's current HEAD SHA into the
73
- # Dockerfile's PRIVATE_PROVIDERS_REF default so the layer's cache key
74
- # changes whenever the private repo changes. Read the repo path from
75
- # the Dockerfile default (authoritative) rather than a secret whose
76
- # format we can't rely on.
77
- REPO="$(sed -n 's/^ARG PRIVATE_PROVIDERS_REPO=//p' /tmp/space/Dockerfile | head -1)"
78
- REPO="${REPO:-TTS-AGI/private-providers}"
79
- if [ -z "${PRIVATE_PROVIDERS_TOKEN:-}" ]; then
80
- echo "note: PRIVATE_PROVIDERS_TOKEN not available to this job"
81
- fi
82
- # Resolve HEAD via the GitHub API (works reliably with fine-grained
83
- # PATs); fall back to git ls-remote. Either way it's non-fatal.
84
- REF="$(curl -sf \
85
- -H "Authorization: Bearer ${PRIVATE_PROVIDERS_TOKEN}" \
86
- -H "Accept: application/vnd.github+json" \
87
- "https://api.github.com/repos/${REPO}/commits/HEAD" \
88
- | python3 -c 'import sys,json;print(json.load(sys.stdin)["sha"])' 2>/dev/null || true)"
89
- if [ -z "$REF" ]; then
90
- REF="$(git ls-remote "https://x-access-token:${PRIVATE_PROVIDERS_TOKEN}@github.com/${REPO}.git" HEAD 2>/dev/null | cut -f1)"
91
- fi
92
- if [ -n "$REF" ]; then
93
- sed -i "s|^ARG PRIVATE_PROVIDERS_REF=.*|ARG PRIVATE_PROVIDERS_REF=${REF}|" /tmp/space/Dockerfile
94
- echo "pinned private providers ref to ${REF:0:7}"
95
- else
96
- # Last resort: a timestamp still busts the cache every deploy (the
97
- # clone always fetches latest), at the cost of no layer reuse.
98
- STAMP="$(date -u +%Y%m%d%H%M%S)"
99
- sed -i "s|^ARG PRIVATE_PROVIDERS_REF=.*|ARG PRIVATE_PROVIDERS_REF=ts-${STAMP}|" /tmp/space/Dockerfile
100
- echo "WARNING: could not resolve private HEAD; busting cache with timestamp ts-${STAMP}" >&2
101
- fi
102
-
103
- # Deploy as a single fresh commit (orphan history). This keeps the
104
- # Space itself (repo + config) intact while ensuring no stray blob
105
- # from past history can block the push (HF rejects binaries even from
106
- # history). The prompt corpus ships via Git LFS; everything else is
107
- # text (bun.lock is the text lockfile).
108
- cd /tmp/space
109
- git init -q
110
- git lfs install --local
111
- # Honor the repo's LFS rules and ensure the corpus is LFS-tracked.
112
- git lfs track "apps/web/data/combined_prompts.txt"
113
- git checkout -q -b main
114
- git add -A
115
- git commit -q -m "Deploy from github.com/${{ github.repository }}@${GITHUB_SHA::7}"
116
- # Sanity: the corpus must be an LFS object, not an inlined blob.
117
- git lfs ls-files | grep -q combined_prompts.txt \
118
- || { echo "ERROR: prompt corpus is not LFS-tracked"; exit 1; }
119
- git push -f "$REMOTE" main
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.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 "actions@github.com"
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:$HF_TOKEN@huggingface.co/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,50 +1,56 @@
1
- # dependencies
2
- node_modules
3
- .pnp
4
- .pnp.*
5
-
6
- # testing
7
- coverage
8
-
9
- # next.js
10
- .next/
11
- out/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- # production
14
- build
15
- dist
 
 
16
 
17
- # misc
18
  .DS_Store
19
- *.pem
20
-
21
- # debug
22
- npm-debug.log*
23
- yarn-debug.log*
24
- *-debug.log*
25
 
26
- # env files
27
- .env*
28
- !.env.example
29
- !*.env.example
30
- !.env.development.local.example
31
 
32
- # vercel
33
- .vercel
34
 
35
- # typescript
36
- *.tsbuildinfo
37
- next-env.d.ts
38
-
39
- # drizzle
40
- drizzle/meta/_journal.json.bak
41
-
42
- # local sqlite
43
- *.db
44
- *.sqlite
45
 
46
- # battle audio cache
47
- .audio-cache/
48
- **/.audio-cache/
49
- .claude/
50
- packages/providers/_private/
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
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/
27
+ venv/
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
 
 
 
51
 
52
+ votes/
 
53
 
54
+ .claude
 
 
 
 
 
 
 
 
 
55
 
56
+ recalibrate_*
 
 
 
 
.prettierignore DELETED
@@ -1,20 +0,0 @@
1
- # build output & deps
2
- node_modules
3
- .next
4
- out
5
- build
6
- dist
7
-
8
- # lockfiles & generated
9
- bun.lockb
10
- *.tsbuildinfo
11
- next-env.d.ts
12
-
13
- # fumadocs generated content (regenerated on install/build)
14
- **/.source/**
15
-
16
- # drizzle generated migrations
17
- **/drizzle/**
18
-
19
- # assets we don't want reformatted
20
- apps/web/public/hf-logo.svg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.prettierrc.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "semi": true,
3
- "singleQuote": false,
4
- "trailingComma": "all",
5
- "printWidth": 80,
6
- "tabWidth": 2,
7
- "plugins": ["prettier-plugin-tailwindcss"],
8
- "tailwindStylesheet": "./apps/web/src/app/globals.css"
9
- }
 
 
 
 
 
 
 
 
 
 
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.
Dockerfile DELETED
@@ -1,115 +0,0 @@
1
- # syntax=docker/dockerfile:1
2
- #
3
- # Combined image for the Hugging Face Space. One container runs two processes
4
- # under supervisord:
5
- # - router (:8080, internal)
6
- # - web (:7860, the port HF exposes)
7
- #
8
- # The database is Postgres on a VPS (DATABASE_URL secret, over a Cloudflare
9
- # tunnel). Generated audio is logged to /audio (a persistent bucket).
10
- FROM oven/bun:1.1.42-debian AS base
11
- WORKDIR /app
12
-
13
- # ── deps ──
14
- FROM base AS deps
15
- # git + ca-certificates for cloning the private providers over HTTPS; build
16
- # toolchain for native modules (better-sqlite3) when no prebuilt binary matches.
17
- RUN apt-get update \
18
- && apt-get install -y --no-install-recommends \
19
- git ca-certificates python3 make g++ \
20
- && rm -rf /var/lib/apt/lists/*
21
- COPY package.json bun.lock ./
22
- COPY apps/web/package.json apps/web/package.json
23
- COPY apps/router/package.json apps/router/package.json
24
- # Copy every workspace manifest (providers grow over time, so copy the tree's
25
- # package.json files rather than enumerating each).
26
- COPY packages/ packages/
27
-
28
- # Pull the private providers straight into the workspace using a build-time
29
- # secret (a read-only GH token Space secret). This keeps the private source out
30
- # of BOTH the Space repo and the build context — the Space can be public without
31
- # exposing it. The token is mounted only for this layer and never persisted.
32
- # PRIVATE_PROVIDERS_REPO defaults to the known repo; override via build arg.
33
- # If the secret is absent the build still succeeds (no private providers).
34
- ARG PRIVATE_PROVIDERS_REPO=TTS-AGI/private-providers
35
- # Cache-buster: the `git clone` below is a fixed command, so Docker would reuse
36
- # a stale layer and silently keep an OLD snapshot of the private repo forever
37
- # (new private providers never appear). The deploy passes the private repo's
38
- # current HEAD SHA here; when it changes, this layer's cache key changes and the
39
- # clone re-runs. Defaults to "dev" for local builds.
40
- ARG PRIVATE_PROVIDERS_REF=31c60ea768fb7b46aa7f04a7916041200052951a
41
- RUN --mount=type=secret,id=PRIVATE_PROVIDERS_TOKEN,mode=0444,required=false \
42
- echo "private providers ref: ${PRIVATE_PROVIDERS_REF}"; \
43
- if [ -s /run/secrets/PRIVATE_PROVIDERS_TOKEN ]; then \
44
- tok="$(cat /run/secrets/PRIVATE_PROVIDERS_TOKEN)"; \
45
- git clone --depth 1 \
46
- "https://x-access-token:${tok}@github.com/${PRIVATE_PROVIDERS_REPO}.git" \
47
- packages/providers/_private \
48
- && rm -rf packages/providers/_private/.git \
49
- && echo "cloned private providers into workspace" \
50
- || { echo "ERROR: token present but private provider clone failed" >&2; exit 1; }; \
51
- else \
52
- echo "no PRIVATE_PROVIDERS_TOKEN secret; building without private providers"; \
53
- fi; \
54
- # Ensure the dir always exists so later COPY --from=deps never fails. A stub
55
- # package.json (private, not a workspace match) keeps it inert when empty.
56
- if [ ! -e packages/providers/_private/package.json ]; then \
57
- mkdir -p packages/providers/_private; \
58
- echo '{"name":"@ttsa-private/_placeholder","private":true,"version":"0.0.0"}' \
59
- > packages/providers/_private/package.json; \
60
- fi
61
-
62
- # Install after the private package is in place so bun links it as a workspace
63
- # (node_modules/@ttsa-private/providers), resolvable by the router.
64
- RUN bun install
65
-
66
- # ── build the web app ──
67
- FROM base AS build
68
- COPY --from=deps /app/node_modules ./node_modules
69
- COPY . .
70
- RUN cd apps/web && bun run build
71
-
72
- # ── runtime ──
73
- FROM base AS runtime
74
- ENV NODE_ENV=production
75
-
76
- # System deps: supervisord, ffmpeg (router audio normalization), ca-certificates
77
- # (verify TLS to the Postgres host). The app connects directly to the VPS
78
- # Postgres over a TLS connection (DATABASE_URL) — no tunnel client in the Space.
79
- RUN apt-get update \
80
- && apt-get install -y --no-install-recommends \
81
- supervisor ffmpeg ca-certificates \
82
- && rm -rf /var/lib/apt/lists/*
83
-
84
- # App: built web + full source for the router (run from source via bun).
85
- COPY --from=deps /app/node_modules ./node_modules
86
- COPY --from=build /app/apps/web/.next/standalone ./standalone
87
- COPY --from=build /app/apps/web/.next/static ./standalone/apps/web/.next/static
88
- COPY --from=build /app/apps/web/public ./standalone/apps/web/public
89
- COPY apps/router ./apps/router
90
- COPY apps/web/drizzle ./apps/web/drizzle
91
- COPY apps/web/drizzle.config.ts ./apps/web/drizzle.config.ts
92
- COPY apps/web/src/server/db ./apps/web/src/server/db
93
- # Public packages from the repo, then the private providers cloned in `deps`
94
- # (kept out of the repo + build context). The node_modules copied from `deps`
95
- # already contains bun's workspace symlink node_modules/@ttsa-private/providers
96
- # -> packages/providers/_private, so the router resolves it via PROVIDER_PLUGINS.
97
- COPY packages ./packages
98
- COPY --from=deps /app/packages/providers/_private ./packages/providers/_private
99
-
100
- # The prompt corpus (Git LFS). Baked into the image so it ships with the deploy
101
- # (the source URL is not guaranteed to stay up). PROMPTS_FILE points the app at
102
- # it; the loader memory-maps it via a line-offset index, never loading it whole.
103
- COPY apps/web/data/combined_prompts.txt /app/data/combined_prompts.txt
104
- ENV PROMPTS_FILE=/app/data/combined_prompts.txt
105
-
106
- # Entrypoint + supervisor config.
107
- COPY deploy/space/entrypoint.sh /entrypoint.sh
108
- COPY deploy/space/supervisord.conf /etc/supervisor/conf.d/tts-arena.conf
109
- RUN chmod +x /entrypoint.sh
110
-
111
- # HF mounts /data and /audio as persistent buckets; create mountpoints.
112
- RUN mkdir -p /data /audio
113
-
114
- EXPOSE 7860
115
- ENTRYPOINT ["/entrypoint.sh"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile.docs DELETED
@@ -1,46 +0,0 @@
1
- # syntax=docker/dockerfile:1
2
- #
3
- # Hugging Face Space image for the docs site (Fumadocs/Next.js). The docs app is
4
- # fully self-contained — it has no @ttsa/* workspace dependencies — so we build
5
- # it in isolation, NOT as part of the monorepo workspace. That avoids pulling in
6
- # unrelated packages (e.g. web's native better-sqlite3) that would need a build
7
- # toolchain and have nothing to do with the docs.
8
- FROM oven/bun:1.1.42-debian AS base
9
- WORKDIR /app/docs
10
-
11
- # ── deps ──
12
- FROM base AS deps
13
- # Standalone install: only the docs package.json (no root workspace context).
14
- COPY apps/docs/package.json ./package.json
15
- RUN bun install
16
-
17
- # ── build ──
18
- FROM base AS build
19
- COPY --from=deps /app/docs/node_modules ./node_modules
20
- COPY apps/docs/ ./
21
- RUN bun run build
22
- # Stage a flat runtime dir. In this isolated build (no parent workspace) Next's
23
- # standalone output is flat — server.js, node_modules and package.json at the
24
- # standalone root — but we locate server.js explicitly so the image is correct
25
- # regardless of any workspace-root inference, then add the static + public dirs
26
- # the standalone server expects beside it.
27
- RUN set -e; \
28
- SA="$PWD/.next/standalone"; \
29
- SERVER="$(find "$SA" -name server.js -not -path '*/node_modules/*' | head -1)"; \
30
- test -n "$SERVER"; \
31
- ROOT="$(dirname "$SERVER")"; \
32
- mkdir -p "$ROOT/.next"; \
33
- cp -a "$PWD/.next/static" "$ROOT/.next/static"; \
34
- [ -d "$PWD/public" ] && cp -a "$PWD/public" "$ROOT/public" || true; \
35
- cp -a "$ROOT" /out
36
-
37
- # ── runtime ──
38
- FROM base AS runtime
39
- ENV NODE_ENV=production
40
- ENV PORT=7860
41
- ENV HOSTNAME=0.0.0.0
42
- EXPOSE 7860
43
-
44
- WORKDIR /app
45
- COPY --from=build /out/ ./
46
- CMD ["bun", "server.js"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
LICENSE CHANGED
@@ -1,202 +1,20 @@
 
1
 
2
- Apache License
3
- Version 2.0, January 2004
4
- http://www.apache.org/licenses/
5
 
6
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
 
 
7
 
8
- 1. Definitions.
 
 
 
9
 
10
- "License" shall mean the terms and conditions for use, reproduction,
11
- and distribution as defined by Sections 1 through 9 of this document.
12
 
13
- "Licensor" shall mean the copyright owner or entity authorized by
14
- the copyright owner that is granting the License.
15
-
16
- "Legal Entity" shall mean the union of the acting entity and all
17
- other entities that control, are controlled by, or are under common
18
- control with that entity. For the purposes of this definition,
19
- "control" means (i) the power, direct or indirect, to cause the
20
- direction or management of such entity, whether by contract or
21
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
- outstanding shares, or (iii) beneficial ownership of such entity.
23
-
24
- "You" (or "Your") shall mean an individual or Legal Entity
25
- exercising permissions granted by this License.
26
-
27
- "Source" form shall mean the preferred form for making modifications,
28
- including but not limited to software source code, documentation
29
- source, and configuration files.
30
-
31
- "Object" form shall mean any form resulting from mechanical
32
- transformation or translation of a Source form, including but
33
- not limited to compiled object code, generated documentation,
34
- and conversions to other media types.
35
-
36
- "Work" shall mean the work of authorship, whether in Source or
37
- Object form, made available under the License, as indicated by a
38
- copyright notice that is included in or attached to the work
39
- (an example is provided in the Appendix below).
40
-
41
- "Derivative Works" shall mean any work, whether in Source or Object
42
- form, that is based on (or derived from) the Work and for which the
43
- editorial revisions, annotations, elaborations, or other modifications
44
- represent, as a whole, an original work of authorship. For the purposes
45
- of this License, Derivative Works shall not include works that remain
46
- separable from, or merely link (or bind by name) to the interfaces of,
47
- the Work and Derivative Works thereof.
48
-
49
- "Contribution" shall mean any work of authorship, including
50
- the original version of the Work and any modifications or additions
51
- to that Work or Derivative Works thereof, that is intentionally
52
- submitted to Licensor for inclusion in the Work by the copyright owner
53
- or by an individual or Legal Entity authorized to submit on behalf of
54
- the copyright owner. For the purposes of this definition, "submitted"
55
- means any form of electronic, verbal, or written communication sent
56
- to the Licensor or its representatives, including but not limited to
57
- communication on electronic mailing lists, source code control systems,
58
- and issue tracking systems that are managed by, or on behalf of, the
59
- Licensor for the purpose of discussing and improving the Work, but
60
- excluding communication that is conspicuously marked or otherwise
61
- designated in writing by the copyright owner as "Not a Contribution."
62
-
63
- "Contributor" shall mean Licensor and any individual or Legal Entity
64
- on behalf of whom a Contribution has been received by Licensor and
65
- subsequently incorporated within the Work.
66
-
67
- 2. Grant of Copyright License. Subject to the terms and conditions of
68
- this License, each Contributor hereby grants to You a perpetual,
69
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
- copyright license to reproduce, prepare Derivative Works of,
71
- publicly display, publicly perform, sublicense, and distribute the
72
- Work and such Derivative Works in Source or Object form.
73
-
74
- 3. Grant of Patent License. Subject to the terms and conditions of
75
- this License, each Contributor hereby grants to You a perpetual,
76
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
- (except as stated in this section) patent license to make, have made,
78
- use, offer to sell, sell, import, and otherwise transfer the Work,
79
- where such license applies only to those patent claims licensable
80
- by such Contributor that are necessarily infringed by their
81
- Contribution(s) alone or by combination of their Contribution(s)
82
- with the Work to which such Contribution(s) was submitted. If You
83
- institute patent litigation against any entity (including a
84
- cross-claim or counterclaim in a lawsuit) alleging that the Work
85
- or a Contribution incorporated within the Work constitutes direct
86
- or contributory patent infringement, then any patent licenses
87
- granted to You under this License for that Work shall terminate
88
- as of the date such litigation is filed.
89
-
90
- 4. Redistribution. You may reproduce and distribute copies of the
91
- Work or Derivative Works thereof in any medium, with or without
92
- modifications, and in Source or Object form, provided that You
93
- meet the following conditions:
94
-
95
- (a) You must give any other recipients of the Work or
96
- Derivative Works a copy of this License; and
97
-
98
- (b) You must cause any modified files to carry prominent notices
99
- stating that You changed the files; and
100
-
101
- (c) You must retain, in the Source form of any Derivative Works
102
- that You distribute, all copyright, patent, trademark, and
103
- attribution notices from the Source form of the Work,
104
- excluding those notices that do not pertain to any part of
105
- the Derivative Works; and
106
-
107
- (d) If the Work includes a "NOTICE" text file as part of its
108
- distribution, then any Derivative Works that You distribute must
109
- include a readable copy of the attribution notices contained
110
- within such NOTICE file, excluding those notices that do not
111
- pertain to any part of the Derivative Works, in at least one
112
- of the following places: within a NOTICE text file distributed
113
- as part of the Derivative Works; within the Source form or
114
- documentation, if provided along with the Derivative Works; or,
115
- within a display generated by the Derivative Works, if and
116
- wherever such third-party notices normally appear. The contents
117
- of the NOTICE file are for informational purposes only and
118
- do not modify the License. You may add Your own attribution
119
- notices within Derivative Works that You distribute, alongside
120
- or as an addendum to the NOTICE text from the Work, provided
121
- that such additional attribution notices cannot be construed
122
- as modifying the License.
123
-
124
- You may add Your own copyright statement to Your modifications and
125
- may provide additional or different license terms and conditions
126
- for use, reproduction, or distribution of Your modifications, or
127
- for any such Derivative Works as a whole, provided Your use,
128
- reproduction, and distribution of the Work otherwise complies with
129
- the conditions stated in this License.
130
-
131
- 5. Submission of Contributions. Unless You explicitly state otherwise,
132
- any Contribution intentionally submitted for inclusion in the Work
133
- by You to the Licensor shall be under the terms and conditions of
134
- this License, without any additional terms or conditions.
135
- Notwithstanding the above, nothing herein shall supersede or modify
136
- the terms of any separate license agreement you may have executed
137
- with Licensor regarding such Contributions.
138
-
139
- 6. Trademarks. This License does not grant permission to use the trade
140
- names, trademarks, service marks, or product names of the Licensor,
141
- except as required for reasonable and customary use in describing the
142
- origin of the Work and reproducing the content of the NOTICE file.
143
-
144
- 7. Disclaimer of Warranty. Unless required by applicable law or
145
- agreed to in writing, Licensor provides the Work (and each
146
- Contributor provides its Contributions) on an "AS IS" BASIS,
147
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
- implied, including, without limitation, any warranties or conditions
149
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
- PARTICULAR PURPOSE. You are solely responsible for determining the
151
- appropriateness of using or redistributing the Work and assume any
152
- risks associated with Your exercise of permissions under this License.
153
-
154
- 8. Limitation of Liability. In no event and under no legal theory,
155
- whether in tort (including negligence), contract, or otherwise,
156
- unless required by applicable law (such as deliberate and grossly
157
- negligent acts) or agreed to in writing, shall any Contributor be
158
- liable to You for damages, including any direct, indirect, special,
159
- incidental, or consequential damages of any character arising as a
160
- result of this License or out of the use or inability to use the
161
- Work (including but not limited to damages for loss of goodwill,
162
- work stoppage, computer failure or malfunction, or any and all
163
- other commercial damages or losses), even if such Contributor
164
- has been advised of the possibility of such damages.
165
-
166
- 9. Accepting Warranty or Additional Liability. While redistributing
167
- the Work or Derivative Works thereof, You may choose to offer,
168
- and charge a fee for, acceptance of support, warranty, indemnity,
169
- or other liability obligations and/or rights consistent with this
170
- License. However, in accepting such obligations, You may act only
171
- on Your own behalf and on Your sole responsibility, not on behalf
172
- of any other Contributor, and only if You agree to indemnify,
173
- defend, and hold each Contributor harmless for any liability
174
- incurred by, or claims asserted against, such Contributor by reason
175
- of your accepting any such warranty or additional liability.
176
-
177
- END OF TERMS AND CONDITIONS
178
-
179
- APPENDIX: How to apply the Apache License to your work.
180
-
181
- To apply the Apache License to your work, attach the following
182
- boilerplate notice, with the fields enclosed by brackets "[]"
183
- replaced with your own identifying information. (Don't include
184
- the brackets!) The text should be enclosed in the appropriate
185
- comment syntax for the file format. We also recommend that a
186
- file or class name and description of purpose be included on the
187
- same "printed page" as the copyright notice for easier
188
- identification within third-party archives.
189
-
190
- Copyright [yyyy] [name of copyright owner]
191
-
192
- Licensed under the Apache License, Version 2.0 (the "License");
193
- you may not use this file except in compliance with the License.
194
- You may obtain a copy of the License at
195
-
196
- http://www.apache.org/licenses/LICENSE-2.0
197
-
198
- Unless required by applicable law or agreed to in writing, software
199
- distributed under the License is distributed on an "AS IS" BASIS,
200
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
- See the License for the specific language governing permissions and
202
- limitations under the License.
 
1
+ Copyright (c) 2024 TTS-AGI Contributors
2
 
3
+ This software is provided ‘as-is’, without any express or implied
4
+ warranty. In no event will the authors be held liable for any damages
5
+ arising from the use of this software.
6
 
7
+ Permission is granted to anyone to use this software for any purpose,
8
+ including commercial applications, and to alter it and redistribute it
9
+ freely, subject to the following restrictions:
10
 
11
+ 1. The origin of this software must not be misrepresented; you must not
12
+ claim that you wrote the original software. If you use this software
13
+ in a product, an acknowledgment in the product documentation would be
14
+ appreciated but is not required.
15
 
16
+ 2. Altered source versions must be plainly marked as such, and must not be
17
+ misrepresented as being the original software.
18
 
19
+ 3. This notice may not be removed or altered from any source
20
+ distribution.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,16 +1,16 @@
1
  ---
2
  title: TTS Arena V2
3
- emoji: 🗣️
4
- colorFrom: indigo
5
- colorTo: purple
6
- sdk: docker
7
- app_port: 7860
 
8
  pinned: true
 
9
  hf_oauth: true
10
- hf_oauth_scopes:
11
- - email
12
  ---
13
 
14
- # TTS Arena V2
15
 
16
- > Source: https://github.com/TTS-AGI/TTS-Arena
 
1
  ---
2
  title: TTS Arena V2
3
+ emoji: 🏆
4
+ colorFrom: blue
5
+ colorTo: blue
6
+ sdk: gradio
7
+ app_file: app.py
8
+ short_description: Vote on the latest TTS models!
9
  pinned: true
10
+
11
  hf_oauth: true
 
 
12
  ---
13
 
14
+ Please see the [GitHub repo](https://github.com/TTS-AGI/TTS-Arena-V2) for information.
15
 
16
+ Join the [Discord server](https://discord.gg/HB8fMR6GTr) for updates and support.
SPACES_DOCS_README.md DELETED
@@ -1,19 +0,0 @@
1
- ---
2
- title: TTS Arena Docs
3
- emoji: 📚
4
- colorFrom: indigo
5
- colorTo: purple
6
- sdk: docker
7
- app_port: 7860
8
- pinned: false
9
- ---
10
-
11
- # TTS Arena Docs
12
-
13
- Documentation for [TTS Arena](https://huggingface.co/spaces/TTS-AGI/TTS-Arena-V2),
14
- served at https://docs.ttsarena.org/.
15
-
16
- > Source: https://github.com/TTS-AGI/TTS-Arena (apps/docs)
17
- >
18
- > Built with Fumadocs + Next.js. This Space is deployed automatically from the
19
- > GitHub repo; do not edit it directly.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,891 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, current_app, jsonify, request, redirect, url_for, flash
2
+ from models import (
3
+ db, User, Model, Vote, EloHistory, ModelType,
4
+ CoordinatedVotingCampaign, CampaignParticipant, UserTimeout,
5
+ get_user_timeouts, get_coordinated_campaigns, resolve_campaign,
6
+ create_user_timeout, cancel_user_timeout, check_user_timeout
7
+ )
8
+ from auth import admin_required
9
+ from security import check_user_security_score
10
+ from sqlalchemy import func, desc, extract, text
11
+ from datetime import datetime, timedelta
12
+ import json
13
+ import os
14
+ from sqlalchemy import or_
15
+
16
+ admin = Blueprint("admin", __name__, url_prefix="/admin")
17
+
18
+ @admin.route("/")
19
+ @admin_required
20
+ def index():
21
+ """Admin dashboard homepage"""
22
+ # Get count statistics
23
+ stats = {
24
+ "total_users": User.query.count(),
25
+ "total_votes": Vote.query.count(),
26
+ "tts_votes": Vote.query.filter_by(model_type=ModelType.TTS).count(),
27
+ "conversational_votes": Vote.query.filter_by(model_type=ModelType.CONVERSATIONAL).count(),
28
+ "tts_models": Model.query.filter_by(model_type=ModelType.TTS).count(),
29
+ "conversational_models": Model.query.filter_by(model_type=ModelType.CONVERSATIONAL).count(),
30
+ }
31
+
32
+ # Get recent votes
33
+ recent_votes = Vote.query.order_by(Vote.vote_date.desc()).limit(10).all()
34
+
35
+ # Get recent users
36
+ recent_users = User.query.order_by(User.join_date.desc()).limit(10).all()
37
+
38
+ # Get daily votes for the past 30 days
39
+ thirty_days_ago = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
40
+
41
+ daily_votes = db.session.query(
42
+ func.date(Vote.vote_date).label('date'),
43
+ func.count().label('count')
44
+ ).filter(Vote.vote_date >= thirty_days_ago).group_by(
45
+ func.date(Vote.vote_date)
46
+ ).order_by(func.date(Vote.vote_date)).all()
47
+
48
+ # Generate a complete list of dates for the past 30 days
49
+ date_list = []
50
+ current_date = datetime.utcnow()
51
+ for i in range(30, -1, -1):
52
+ date_list.append((current_date - timedelta(days=i)).date())
53
+
54
+ # Create a dictionary with actual vote counts
55
+ vote_counts = {day.date: day.count for day in daily_votes}
56
+
57
+ # Build complete datasets including days with zero votes
58
+ formatted_dates = [date.strftime("%Y-%m-%d") for date in date_list]
59
+ vote_counts_list = [vote_counts.get(date, 0) for date in date_list]
60
+
61
+ daily_votes_data = {
62
+ "labels": formatted_dates,
63
+ "counts": vote_counts_list
64
+ }
65
+
66
+ # Get top models
67
+ top_tts_models = Model.query.filter_by(
68
+ model_type=ModelType.TTS
69
+ ).order_by(Model.current_elo.desc()).limit(5).all()
70
+
71
+ top_conversational_models = Model.query.filter_by(
72
+ model_type=ModelType.CONVERSATIONAL
73
+ ).order_by(Model.current_elo.desc()).limit(5).all()
74
+
75
+ return render_template(
76
+ "admin/index.html",
77
+ stats=stats,
78
+ recent_votes=recent_votes,
79
+ recent_users=recent_users,
80
+ daily_votes_data=json.dumps(daily_votes_data),
81
+ top_tts_models=top_tts_models,
82
+ top_conversational_models=top_conversational_models
83
+ )
84
+
85
+ @admin.route("/models")
86
+ @admin_required
87
+ def models():
88
+ """Manage models"""
89
+ tts_models = Model.query.filter_by(model_type=ModelType.TTS).order_by(Model.name).all()
90
+ conversational_models = Model.query.filter_by(model_type=ModelType.CONVERSATIONAL).order_by(Model.name).all()
91
+
92
+ return render_template(
93
+ "admin/models.html",
94
+ tts_models=tts_models,
95
+ conversational_models=conversational_models
96
+ )
97
+
98
+
99
+ @admin.route("/model/<model_id>", methods=["GET", "POST"])
100
+ @admin_required
101
+ def edit_model(model_id):
102
+ """Edit a model"""
103
+ model = Model.query.get_or_404(model_id)
104
+
105
+ if request.method == "POST":
106
+ model.name = request.form.get("name")
107
+ model.is_active = "is_active" in request.form
108
+ model.is_open = "is_open" in request.form
109
+ model.model_url = request.form.get("model_url")
110
+
111
+ db.session.commit()
112
+ flash(f"Model '{model.name}' updated successfully", "success")
113
+ return redirect(url_for("admin.models"))
114
+
115
+ return render_template("admin/edit_model.html", model=model)
116
+
117
+ @admin.route("/users")
118
+ @admin_required
119
+ def users():
120
+ """Manage users"""
121
+ users = User.query.order_by(User.username).all()
122
+ admin_users = os.getenv("ADMIN_USERS", "").split(",")
123
+ admin_users = [username.strip() for username in admin_users]
124
+
125
+ # Calculate security scores for all users
126
+ users_with_scores = []
127
+ for user in users:
128
+ score, factors = check_user_security_score(user.id)
129
+ users_with_scores.append({
130
+ 'user': user,
131
+ 'security_score': score,
132
+ 'security_factors': factors
133
+ })
134
+
135
+ # Sort by security score (lowest first to highlight problematic users)
136
+ users_with_scores.sort(key=lambda x: x['security_score'])
137
+
138
+ return render_template("admin/users.html", users_with_scores=users_with_scores, admin_users=admin_users)
139
+
140
+ @admin.route("/user/<int:user_id>")
141
+ @admin_required
142
+ def user_detail(user_id):
143
+ """View user details"""
144
+ user = User.query.get_or_404(user_id)
145
+
146
+ # Get security score and factors
147
+ security_score, security_factors = check_user_security_score(user_id)
148
+
149
+ # Get user votes
150
+ recent_votes = Vote.query.filter_by(user_id=user_id).order_by(Vote.vote_date.desc()).limit(20).all()
151
+
152
+ # Get vote statistics
153
+ tts_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.TTS).count()
154
+ conversational_votes = Vote.query.filter_by(user_id=user_id, model_type=ModelType.CONVERSATIONAL).count()
155
+
156
+ # Get comprehensive model bias analysis
157
+ # This counts how often each model was chosen vs how often it appeared
158
+ model_bias_analysis = []
159
+
160
+ # Get all votes by this user
161
+ user_votes = Vote.query.filter_by(user_id=user_id).all()
162
+
163
+ if user_votes:
164
+ model_stats = {}
165
+
166
+ for vote in user_votes:
167
+ # Track model_chosen
168
+ chosen_id = vote.model_chosen
169
+ rejected_id = vote.model_rejected
170
+
171
+ # Initialize model stats if not exists
172
+ if chosen_id not in model_stats:
173
+ model_stats[chosen_id] = {'chosen': 0, 'appeared': 0, 'name': None}
174
+ if rejected_id not in model_stats:
175
+ model_stats[rejected_id] = {'chosen': 0, 'appeared': 0, 'name': None}
176
+
177
+ # Count appearances and choices
178
+ model_stats[chosen_id]['chosen'] += 1
179
+ model_stats[chosen_id]['appeared'] += 1
180
+ model_stats[rejected_id]['appeared'] += 1
181
+
182
+ # Get model names and calculate bias ratios
183
+ for model_id, stats in model_stats.items():
184
+ model = Model.query.get(model_id)
185
+ if model:
186
+ stats['name'] = model.name
187
+ stats['bias_ratio'] = stats['chosen'] / stats['appeared'] if stats['appeared'] > 0 else 0
188
+ stats['model_id'] = model_id
189
+
190
+ # Sort by bias ratio (highest bias first) and take top 5
191
+ model_bias_analysis = sorted(
192
+ [stats for stats in model_stats.values() if stats['name'] is not None],
193
+ key=lambda x: x['bias_ratio'],
194
+ reverse=True
195
+ )[:5]
196
+
197
+ return render_template(
198
+ "admin/user_detail.html",
199
+ user=user,
200
+ security_score=security_score,
201
+ security_factors=security_factors,
202
+ recent_votes=recent_votes,
203
+ tts_votes=tts_votes,
204
+ conversational_votes=conversational_votes,
205
+ model_bias_analysis=model_bias_analysis,
206
+ total_votes=tts_votes + conversational_votes
207
+ )
208
+
209
+ @admin.route("/votes")
210
+ @admin_required
211
+ def votes():
212
+ """View recent votes"""
213
+ page = request.args.get('page', 1, type=int)
214
+ per_page = 50
215
+
216
+ # Get votes with pagination
217
+ votes_pagination = Vote.query.order_by(
218
+ Vote.vote_date.desc()
219
+ ).paginate(page=page, per_page=per_page)
220
+
221
+ return render_template(
222
+ "admin/votes.html",
223
+ votes=votes_pagination.items,
224
+ pagination=votes_pagination
225
+ )
226
+
227
+ @admin.route("/statistics")
228
+ @admin_required
229
+ def statistics():
230
+ """View detailed statistics"""
231
+ # Get daily votes for the past 30 days by model type
232
+ thirty_days_ago = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
233
+
234
+ tts_daily_votes = db.session.query(
235
+ func.date(Vote.vote_date).label('date'),
236
+ func.count().label('count')
237
+ ).filter(
238
+ Vote.vote_date >= thirty_days_ago,
239
+ Vote.model_type == ModelType.TTS
240
+ ).group_by(
241
+ func.date(Vote.vote_date)
242
+ ).order_by(func.date(Vote.vote_date)).all()
243
+
244
+ conv_daily_votes = db.session.query(
245
+ func.date(Vote.vote_date).label('date'),
246
+ func.count().label('count')
247
+ ).filter(
248
+ Vote.vote_date >= thirty_days_ago,
249
+ Vote.model_type == ModelType.CONVERSATIONAL
250
+ ).group_by(
251
+ func.date(Vote.vote_date)
252
+ ).order_by(func.date(Vote.vote_date)).all()
253
+
254
+ # Monthly new users
255
+ monthly_users = db.session.query(
256
+ extract('year', User.join_date).label('year'),
257
+ extract('month', User.join_date).label('month'),
258
+ func.count().label('count')
259
+ ).group_by(
260
+ 'year', 'month'
261
+ ).order_by('year', 'month').all()
262
+
263
+ # Generate a complete list of dates for the past 30 days
264
+ date_list = []
265
+ current_date = datetime.utcnow()
266
+ for i in range(30, -1, -1):
267
+ date_list.append((current_date - timedelta(days=i)).date())
268
+
269
+ # Create dictionaries with actual vote counts
270
+ tts_vote_counts = {day.date: day.count for day in tts_daily_votes}
271
+ conv_vote_counts = {day.date: day.count for day in conv_daily_votes}
272
+
273
+ # Format dates consistently for charts
274
+ formatted_dates = [date.strftime("%Y-%m-%d") for date in date_list]
275
+
276
+ # Build complete datasets including days with zero votes
277
+ tts_counts = [tts_vote_counts.get(date, 0) for date in date_list]
278
+ conv_counts = [conv_vote_counts.get(date, 0) for date in date_list]
279
+
280
+ # Generate all month/year combinations for the past 12 months
281
+ current_date = datetime.utcnow()
282
+ month_list = []
283
+ for i in range(11, -1, -1):
284
+ past_date = current_date - timedelta(days=i*30) # Approximate
285
+ month_list.append((past_date.year, past_date.month))
286
+
287
+ # Create a dictionary with actual user counts
288
+ user_counts = {(record.year, record.month): record.count for record in monthly_users}
289
+
290
+ # Build complete monthly datasets including months with zero new users
291
+ monthly_labels = [f"{month}/{year}" for year, month in month_list]
292
+ monthly_counts = [user_counts.get((year, month), 0) for year, month in month_list]
293
+
294
+ # Model performance over time
295
+ top_models = Model.query.order_by(Model.match_count.desc()).limit(5).all()
296
+
297
+ # Get first and last timestamp to create a consistent timeline
298
+ earliest = datetime.utcnow() - timedelta(days=30) # Default to 30 days ago
299
+ latest = datetime.utcnow() # Default to now
300
+
301
+ # Find actual earliest and latest timestamps across all models
302
+ has_elo_history = False
303
+ for model in top_models:
304
+ first = EloHistory.query.filter_by(model_id=model.id).order_by(EloHistory.timestamp).first()
305
+ last = EloHistory.query.filter_by(model_id=model.id).order_by(EloHistory.timestamp.desc()).first()
306
+
307
+ if first and last:
308
+ has_elo_history = True
309
+ if first.timestamp < earliest:
310
+ earliest = first.timestamp
311
+ if last.timestamp > latest:
312
+ latest = last.timestamp
313
+
314
+ # If no history was found, use a default range of the last 30 days
315
+ if not has_elo_history:
316
+ earliest = datetime.utcnow() - timedelta(days=30)
317
+ latest = datetime.utcnow()
318
+
319
+ # Make sure the date range is valid (earliest before latest)
320
+ if earliest > latest:
321
+ earliest = latest - timedelta(days=30)
322
+
323
+ # Generate a list of dates for the ELO history timeline
324
+ # Using 1-day intervals for a smoother chart
325
+ elo_dates = []
326
+ current = earliest
327
+ while current <= latest:
328
+ elo_dates.append(current.date())
329
+ current += timedelta(days=1)
330
+
331
+ # Format dates consistently
332
+ formatted_elo_dates = [date.strftime("%Y-%m-%d") for date in elo_dates]
333
+
334
+ model_history = {}
335
+
336
+ # Initialize empty data for all top models
337
+ for model in top_models:
338
+ model_history[model.name] = {
339
+ "timestamps": formatted_elo_dates,
340
+ "scores": [None] * len(formatted_elo_dates) # Initialize with None values
341
+ }
342
+
343
+ history = EloHistory.query.filter_by(
344
+ model_id=model.id
345
+ ).order_by(EloHistory.timestamp).all()
346
+
347
+ if history:
348
+ # Create a dictionary mapping dates to scores
349
+ history_dict = {}
350
+ for h in history:
351
+ date_key = h.timestamp.date().strftime("%Y-%m-%d")
352
+ history_dict[date_key] = h.elo_score
353
+
354
+ # Fill in missing dates with the previous score
355
+ last_score = model.current_elo # Default to current ELO if no history
356
+ scores = []
357
+
358
+ for date in formatted_elo_dates:
359
+ if date in history_dict:
360
+ last_score = history_dict[date]
361
+ scores.append(last_score)
362
+
363
+ model_history[model.name]["scores"] = scores
364
+ else:
365
+ # If no history, use the current Elo for all dates
366
+ model_history[model.name]["scores"] = [model.current_elo] * len(formatted_elo_dates)
367
+
368
+ chart_data = {
369
+ "dailyVotes": {
370
+ "labels": formatted_dates,
371
+ "ttsCounts": tts_counts,
372
+ "convCounts": conv_counts
373
+ },
374
+ "monthlyUsers": {
375
+ "labels": monthly_labels,
376
+ "counts": monthly_counts
377
+ },
378
+ "modelHistory": model_history
379
+ }
380
+
381
+ return render_template(
382
+ "admin/statistics.html",
383
+ chart_data=json.dumps(chart_data)
384
+ )
385
+
386
+ @admin.route("/activity")
387
+ @admin_required
388
+ def activity():
389
+ """View recent text generations"""
390
+ # Check if we have any active sessions from app.py
391
+ tts_session_count = 0
392
+ conversational_session_count = 0
393
+
394
+ # Access global variables from app.py through current_app
395
+ if hasattr(current_app, 'tts_sessions'):
396
+ tts_session_count = len(current_app.tts_sessions)
397
+ else: # Try to access through app module
398
+ from app import tts_sessions
399
+ tts_session_count = len(tts_sessions)
400
+
401
+ if hasattr(current_app, 'conversational_sessions'):
402
+ conversational_session_count = len(current_app.conversational_sessions)
403
+ else: # Try to access through app module
404
+ from app import conversational_sessions
405
+ conversational_session_count = len(conversational_sessions)
406
+
407
+ # Get recent votes which represent completed generations
408
+ recent_tts_votes = Vote.query.filter_by(
409
+ model_type=ModelType.TTS
410
+ ).order_by(Vote.vote_date.desc()).limit(20).all()
411
+
412
+ recent_conv_votes = Vote.query.filter_by(
413
+ model_type=ModelType.CONVERSATIONAL
414
+ ).order_by(Vote.vote_date.desc()).limit(20).all()
415
+
416
+ # Get votes per hour for the last 24 hours
417
+ current_time = datetime.utcnow()
418
+ last_24h = current_time.replace(minute=0, second=0, microsecond=0) - timedelta(hours=24)
419
+
420
+ # Use SQLite-compatible date formatting
421
+ hourly_votes = db.session.query(
422
+ func.strftime('%Y-%m-%d %H:00', Vote.vote_date).label('hour'),
423
+ func.count().label('count')
424
+ ).filter(
425
+ Vote.vote_date >= last_24h
426
+ ).group_by('hour').order_by('hour').all()
427
+
428
+ # Generate all hours for the past 24 hours with correct hour formatting
429
+ hour_list = []
430
+ for i in range(24, -1, -1):
431
+ # Calculate the hour time and truncate to hour
432
+ hour_time = current_time - timedelta(hours=i)
433
+ hour_time = hour_time.replace(minute=0, second=0, microsecond=0)
434
+ hour_list.append(hour_time.strftime('%Y-%m-%d %H:00'))
435
+
436
+ # Create a dictionary with actual vote counts
437
+ vote_counts = {hour.hour: hour.count for hour in hourly_votes}
438
+
439
+ # Build complete hourly datasets including hours with zero votes
440
+ hourly_data = {
441
+ "labels": hour_list,
442
+ "counts": [vote_counts.get(hour, 0) for hour in hour_list]
443
+ }
444
+
445
+ return render_template(
446
+ "admin/activity.html",
447
+ tts_session_count=tts_session_count,
448
+ conversational_session_count=conversational_session_count,
449
+ recent_tts_votes=recent_tts_votes,
450
+ recent_conv_votes=recent_conv_votes,
451
+ hourly_data=json.dumps(hourly_data)
452
+ )
453
+
454
+ @admin.route("/analytics")
455
+ @admin_required
456
+ def analytics():
457
+ """View analytics data including session duration, IP addresses, etc."""
458
+
459
+ # Get analytics statistics
460
+ analytics_stats = {}
461
+
462
+ try:
463
+ # Session duration statistics
464
+ duration_stats = db.session.execute(text("""
465
+ SELECT
466
+ AVG(session_duration_seconds) as avg_duration,
467
+ MIN(session_duration_seconds) as min_duration,
468
+ MAX(session_duration_seconds) as max_duration,
469
+ COUNT(session_duration_seconds) as total_with_duration
470
+ FROM vote
471
+ WHERE session_duration_seconds IS NOT NULL
472
+ """)).fetchone()
473
+
474
+ analytics_stats['duration'] = {
475
+ 'avg': round(duration_stats.avg_duration, 2) if duration_stats.avg_duration else 0,
476
+ 'min': round(duration_stats.min_duration, 2) if duration_stats.min_duration else 0,
477
+ 'max': round(duration_stats.max_duration, 2) if duration_stats.max_duration else 0,
478
+ 'total': duration_stats.total_with_duration or 0
479
+ }
480
+
481
+ # Cache hit statistics
482
+ cache_stats = db.session.execute(text("""
483
+ SELECT
484
+ cache_hit,
485
+ COUNT(*) as count
486
+ FROM vote
487
+ WHERE cache_hit IS NOT NULL
488
+ GROUP BY cache_hit
489
+ """)).fetchall()
490
+
491
+ analytics_stats['cache'] = {
492
+ 'hits': 0,
493
+ 'misses': 0,
494
+ 'total': 0
495
+ }
496
+
497
+ for stat in cache_stats:
498
+ if stat.cache_hit:
499
+ analytics_stats['cache']['hits'] = stat.count
500
+ else:
501
+ analytics_stats['cache']['misses'] = stat.count
502
+ analytics_stats['cache']['total'] += stat.count
503
+
504
+ # Top IP address regions (anonymized)
505
+ ip_stats = db.session.execute(text("""
506
+ SELECT
507
+ ip_address_partial,
508
+ COUNT(*) as count
509
+ FROM vote
510
+ WHERE ip_address_partial IS NOT NULL
511
+ GROUP BY ip_address_partial
512
+ ORDER BY count DESC
513
+ LIMIT 10
514
+ """)).fetchall()
515
+
516
+ analytics_stats['top_ips'] = [
517
+ {'ip': stat.ip_address_partial, 'count': stat.count}
518
+ for stat in ip_stats
519
+ ]
520
+
521
+ # User agent statistics (top browsers/devices)
522
+ ua_stats = db.session.execute(text("""
523
+ SELECT
524
+ CASE
525
+ WHEN user_agent LIKE '%Chrome%' THEN 'Chrome'
526
+ WHEN user_agent LIKE '%Firefox%' THEN 'Firefox'
527
+ WHEN user_agent LIKE '%Safari%' AND user_agent NOT LIKE '%Chrome%' THEN 'Safari'
528
+ WHEN user_agent LIKE '%Edge%' THEN 'Edge'
529
+ WHEN user_agent LIKE '%Mobile%' OR user_agent LIKE '%Android%' THEN 'Mobile'
530
+ ELSE 'Other'
531
+ END as browser,
532
+ COUNT(*) as count
533
+ FROM vote
534
+ WHERE user_agent IS NOT NULL
535
+ GROUP BY browser
536
+ ORDER BY count DESC
537
+ """)).fetchall()
538
+
539
+ analytics_stats['browsers'] = [
540
+ {'browser': stat.browser, 'count': stat.count}
541
+ for stat in ua_stats
542
+ ]
543
+
544
+ # Recent votes with analytics data
545
+ recent_analytics = db.session.execute(text("""
546
+ SELECT
547
+ v.id,
548
+ v.vote_date,
549
+ v.session_duration_seconds,
550
+ v.ip_address_partial,
551
+ v.cache_hit,
552
+ v.model_type,
553
+ u.username,
554
+ m1.name as chosen_model,
555
+ m2.name as rejected_model
556
+ FROM vote v
557
+ LEFT JOIN user u ON v.user_id = u.id
558
+ LEFT JOIN model m1 ON v.model_chosen = m1.id
559
+ LEFT JOIN model m2 ON v.model_rejected = m2.id
560
+ WHERE v.session_duration_seconds IS NOT NULL
561
+ ORDER BY v.vote_date DESC
562
+ LIMIT 20
563
+ """)).fetchall()
564
+
565
+ analytics_stats['recent_votes'] = [
566
+ {
567
+ 'id': vote.id,
568
+ 'vote_date': vote.vote_date if isinstance(vote.vote_date, datetime) else datetime.fromisoformat(str(vote.vote_date).replace('Z', '+00:00')) if vote.vote_date else None,
569
+ 'duration': round(vote.session_duration_seconds, 2) if vote.session_duration_seconds else None,
570
+ 'ip': vote.ip_address_partial,
571
+ 'cache_hit': vote.cache_hit,
572
+ 'model_type': vote.model_type,
573
+ 'username': vote.username,
574
+ 'chosen_model': vote.chosen_model,
575
+ 'rejected_model': vote.rejected_model
576
+ }
577
+ for vote in recent_analytics
578
+ ]
579
+
580
+ except Exception as e:
581
+ flash(f"Error retrieving analytics data: {str(e)}", "error")
582
+ analytics_stats = {}
583
+
584
+ return render_template(
585
+ "admin/analytics.html",
586
+ analytics_stats=analytics_stats
587
+ )
588
+
589
+ @admin.route("/security")
590
+ @admin_required
591
+ def security():
592
+ """View security monitoring data and suspicious activity."""
593
+ try:
594
+ from security import (
595
+ detect_suspicious_voting_patterns,
596
+ detect_coordinated_voting,
597
+ check_user_security_score,
598
+ detect_model_bias
599
+ )
600
+
601
+ # Get recent suspicious users
602
+ recent_users = User.query.order_by(User.join_date.desc()).limit(50).all()
603
+ suspicious_users = []
604
+
605
+ for user in recent_users:
606
+ score, factors = check_user_security_score(user.id)
607
+ if score < 50: # Flag users with low security scores
608
+ suspicious_users.append({
609
+ 'user': user,
610
+ 'score': score,
611
+ 'factors': factors
612
+ })
613
+
614
+ # Sort by lowest score first
615
+ suspicious_users.sort(key=lambda x: x['score'])
616
+
617
+ # Check for coordinated voting on top models
618
+ top_models = Model.query.order_by(Model.current_elo.desc()).limit(10).all()
619
+ coordinated_campaigns = []
620
+
621
+ for model in top_models:
622
+ is_coordinated, user_count, vote_count, suspicious_users_list = detect_coordinated_voting(model.id)
623
+ if is_coordinated:
624
+ coordinated_campaigns.append({
625
+ 'model': model,
626
+ 'user_count': user_count,
627
+ 'vote_count': vote_count,
628
+ 'suspicious_users': suspicious_users_list
629
+ })
630
+
631
+ # Get users with high model bias
632
+ biased_users = []
633
+ for model in top_models:
634
+ # Check recent voters for this model
635
+ recent_voters = db.session.query(Vote.user_id).filter(
636
+ Vote.model_chosen == model.id
637
+ ).distinct().limit(20).all()
638
+
639
+ for voter in recent_voters:
640
+ if voter.user_id:
641
+ is_biased, bias_ratio, votes_for_model, total_votes = detect_model_bias(
642
+ voter.user_id, model.id
643
+ )
644
+ if is_biased and total_votes >= 5:
645
+ user = User.query.get(voter.user_id)
646
+ if user:
647
+ biased_users.append({
648
+ 'user': user,
649
+ 'model': model,
650
+ 'bias_ratio': bias_ratio,
651
+ 'votes_for_model': votes_for_model,
652
+ 'total_votes': total_votes
653
+ })
654
+
655
+ # Remove duplicates and sort by bias ratio
656
+ seen_users = set()
657
+ unique_biased_users = []
658
+ for item in biased_users:
659
+ user_model_key = (item['user'].id, item['model'].id)
660
+ if user_model_key not in seen_users:
661
+ seen_users.add(user_model_key)
662
+ unique_biased_users.append(item)
663
+
664
+ unique_biased_users.sort(key=lambda x: x['bias_ratio'], reverse=True)
665
+
666
+ # Get recent security blocks from logs (if available)
667
+ security_blocks = []
668
+ try:
669
+ # This would require parsing application logs
670
+ # For now, we'll show a placeholder
671
+ pass
672
+ except Exception:
673
+ pass
674
+
675
+ return render_template(
676
+ "admin/security.html",
677
+ suspicious_users=suspicious_users[:20], # Limit to top 20
678
+ coordinated_campaigns=coordinated_campaigns,
679
+ biased_users=unique_biased_users[:20], # Limit to top 20
680
+ security_blocks=security_blocks
681
+ )
682
+
683
+ except ImportError:
684
+ flash("Security module not available", "error")
685
+ return redirect(url_for("admin.index"))
686
+ except Exception as e:
687
+ flash(f"Error loading security data: {str(e)}", "error")
688
+ return redirect(url_for("admin.index"))
689
+
690
+
691
+ @admin.route("/timeouts")
692
+ @admin_required
693
+ def timeouts():
694
+ """Manage user timeouts"""
695
+ # Get active timeouts
696
+ active_timeouts = get_user_timeouts(active_only=True, limit=100)
697
+
698
+ # Get recent expired/cancelled timeouts
699
+ recent_inactive = UserTimeout.query.filter(
700
+ or_(
701
+ UserTimeout.is_active == False,
702
+ UserTimeout.expires_at <= datetime.utcnow()
703
+ )
704
+ ).order_by(UserTimeout.created_at.desc()).limit(50).all()
705
+
706
+ # Get coordinated campaigns for context
707
+ recent_campaigns = get_coordinated_campaigns(limit=20)
708
+
709
+ return render_template(
710
+ "admin/timeouts.html",
711
+ active_timeouts=active_timeouts,
712
+ recent_inactive=recent_inactive,
713
+ recent_campaigns=recent_campaigns
714
+ )
715
+
716
+
717
+ @admin.route("/timeout/create", methods=["POST"])
718
+ @admin_required
719
+ def create_timeout():
720
+ """Create a new user timeout"""
721
+ try:
722
+ user_id = request.form.get("user_id", type=int)
723
+ reason = request.form.get("reason", "").strip()
724
+ timeout_type = request.form.get("timeout_type", "manual")
725
+ duration_days = request.form.get("duration_days", type=int)
726
+
727
+ if not all([user_id, reason, duration_days]):
728
+ flash("All fields are required", "error")
729
+ return redirect(url_for("admin.timeouts"))
730
+
731
+ if duration_days < 1 or duration_days > 365:
732
+ flash("Duration must be between 1 and 365 days", "error")
733
+ return redirect(url_for("admin.timeouts"))
734
+
735
+ # Check if user exists
736
+ user = User.query.get(user_id)
737
+ if not user:
738
+ flash("User not found", "error")
739
+ return redirect(url_for("admin.timeouts"))
740
+
741
+ # Check if user already has an active timeout
742
+ is_timed_out, existing_timeout = check_user_timeout(user_id)
743
+ if is_timed_out:
744
+ flash(f"User {user.username} already has an active timeout until {existing_timeout.expires_at}", "error")
745
+ return redirect(url_for("admin.timeouts"))
746
+
747
+ # Create timeout
748
+ from flask_login import current_user
749
+ timeout = create_user_timeout(
750
+ user_id=user_id,
751
+ reason=reason,
752
+ timeout_type=timeout_type,
753
+ duration_days=duration_days,
754
+ created_by=current_user.id if current_user.is_authenticated else None
755
+ )
756
+
757
+ flash(f"Timeout created for {user.username} (expires: {timeout.expires_at})", "success")
758
+
759
+ except Exception as e:
760
+ flash(f"Error creating timeout: {str(e)}", "error")
761
+
762
+ return redirect(url_for("admin.timeouts"))
763
+
764
+
765
+ @admin.route("/timeout/cancel/<int:timeout_id>", methods=["POST"])
766
+ @admin_required
767
+ def cancel_timeout(timeout_id):
768
+ """Cancel an active timeout"""
769
+ try:
770
+ cancel_reason = request.form.get("cancel_reason", "").strip()
771
+ if not cancel_reason:
772
+ flash("Cancel reason is required", "error")
773
+ return redirect(url_for("admin.timeouts"))
774
+
775
+ from flask_login import current_user
776
+ success, message = cancel_user_timeout(
777
+ timeout_id=timeout_id,
778
+ cancelled_by=current_user.id if current_user.is_authenticated else None,
779
+ cancel_reason=cancel_reason
780
+ )
781
+
782
+ if success:
783
+ flash(message, "success")
784
+ else:
785
+ flash(message, "error")
786
+
787
+ except Exception as e:
788
+ flash(f"Error cancelling timeout: {str(e)}", "error")
789
+
790
+ return redirect(url_for("admin.timeouts"))
791
+
792
+
793
+ @admin.route("/campaigns")
794
+ @admin_required
795
+ def campaigns():
796
+ """View and manage coordinated voting campaigns"""
797
+ status_filter = request.args.get("status", "all")
798
+
799
+ if status_filter == "all":
800
+ campaigns = get_coordinated_campaigns(limit=100)
801
+ else:
802
+ campaigns = get_coordinated_campaigns(status=status_filter, limit=100)
803
+
804
+ # Get campaign statistics
805
+ stats = {
806
+ "total": CoordinatedVotingCampaign.query.count(),
807
+ "active": CoordinatedVotingCampaign.query.filter_by(status="active").count(),
808
+ "resolved": CoordinatedVotingCampaign.query.filter_by(status="resolved").count(),
809
+ "false_positive": CoordinatedVotingCampaign.query.filter_by(status="false_positive").count(),
810
+ }
811
+
812
+ return render_template(
813
+ "admin/campaigns.html",
814
+ campaigns=campaigns,
815
+ stats=stats,
816
+ current_filter=status_filter
817
+ )
818
+
819
+
820
+ @admin.route("/campaign/<int:campaign_id>")
821
+ @admin_required
822
+ def campaign_detail(campaign_id):
823
+ """View detailed information about a coordinated voting campaign"""
824
+ campaign = CoordinatedVotingCampaign.query.get_or_404(campaign_id)
825
+
826
+ # Get participants with user details
827
+ participants = db.session.query(CampaignParticipant, User).join(
828
+ User, CampaignParticipant.user_id == User.id
829
+ ).filter(CampaignParticipant.campaign_id == campaign_id).all()
830
+
831
+ # Get related timeouts
832
+ related_timeouts = UserTimeout.query.filter_by(
833
+ related_campaign_id=campaign_id
834
+ ).all()
835
+
836
+ return render_template(
837
+ "admin/campaign_detail.html",
838
+ campaign=campaign,
839
+ participants=participants,
840
+ related_timeouts=related_timeouts
841
+ )
842
+
843
+
844
+ @admin.route("/campaign/resolve/<int:campaign_id>", methods=["POST"])
845
+ @admin_required
846
+ def resolve_campaign_route(campaign_id):
847
+ """Mark a campaign as resolved"""
848
+ try:
849
+ status = request.form.get("status")
850
+ admin_notes = request.form.get("admin_notes", "").strip()
851
+
852
+ if status not in ["resolved", "false_positive"]:
853
+ flash("Invalid status", "error")
854
+ return redirect(url_for("admin.campaign_detail", campaign_id=campaign_id))
855
+
856
+ from flask_login import current_user
857
+ success, message = resolve_campaign(
858
+ campaign_id=campaign_id,
859
+ resolved_by=current_user.id if current_user.is_authenticated else None,
860
+ status=status,
861
+ admin_notes=admin_notes
862
+ )
863
+
864
+ if success:
865
+ flash(f"Campaign marked as {status}", "success")
866
+ else:
867
+ flash(message, "error")
868
+
869
+ except Exception as e:
870
+ flash(f"Error resolving campaign: {str(e)}", "error")
871
+
872
+ return redirect(url_for("admin.campaign_detail", campaign_id=campaign_id))
873
+
874
+
875
+ @admin.route("/api/user-search")
876
+ @admin_required
877
+ def user_search():
878
+ """Search for users by username (for timeout creation)"""
879
+ query = request.args.get("q", "").strip()
880
+ if len(query) < 2:
881
+ return jsonify([])
882
+
883
+ users = User.query.filter(
884
+ User.username.ilike(f"%{query}%")
885
+ ).limit(10).all()
886
+
887
+ return jsonify([{
888
+ "id": user.id,
889
+ "username": user.username,
890
+ "join_date": user.join_date.strftime("%Y-%m-%d") if user.join_date else "N/A"
891
+ } for user in users])
app.py ADDED
@@ -0,0 +1,1694 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import threading # Added for locking
7
+ from sqlalchemy import or_ # Added for vote counting query
8
+ from datasets import load_dataset
9
+
10
+ year = datetime.now().year
11
+ month = datetime.now().month
12
+
13
+ # Check if running in a Huggin Face Space
14
+ IS_SPACES = False
15
+ if os.getenv("SPACE_REPO_NAME"):
16
+ print("Running in a Hugging Face Space 🤗")
17
+ IS_SPACES = True
18
+
19
+ # Setup database sync for HF Spaces
20
+ if not os.path.exists("instance/tts_arena.db"):
21
+ os.makedirs("instance", exist_ok=True)
22
+ try:
23
+ print("Database not found, downloading from HF dataset...")
24
+ hf_hub_download(
25
+ repo_id="TTS-AGI/database-arena-v2",
26
+ filename="tts_arena.db",
27
+ repo_type="dataset",
28
+ local_dir="instance",
29
+ token=os.getenv("HF_TOKEN"),
30
+ )
31
+ print("Database downloaded successfully ✅")
32
+ except Exception as e:
33
+ print(f"Error downloading database from HF dataset: {str(e)} ⚠️")
34
+
35
+ from flask import (
36
+ Flask,
37
+ render_template,
38
+ g,
39
+ request,
40
+ jsonify,
41
+ send_file,
42
+ redirect,
43
+ url_for,
44
+ session,
45
+ abort,
46
+ )
47
+ from flask_login import LoginManager, current_user
48
+ from models import *
49
+ from models import (
50
+ hash_sentence, is_sentence_consumed, mark_sentence_consumed,
51
+ get_unconsumed_sentences, get_consumed_sentences_count, get_random_unconsumed_sentence
52
+ )
53
+ from auth import auth, init_oauth, is_admin
54
+ from admin import admin
55
+ from security import is_vote_allowed, check_user_security_score, detect_coordinated_voting
56
+ import os
57
+ from dotenv import load_dotenv
58
+ from flask_limiter import Limiter
59
+ from flask_limiter.util import get_remote_address
60
+ import uuid
61
+ import tempfile
62
+ import shutil
63
+ from tts import predict_tts
64
+ import random
65
+ import json
66
+ from datetime import datetime, timedelta
67
+ from flask_migrate import Migrate
68
+ import requests
69
+ import functools
70
+ import time # Added for potential retries
71
+ from langdetect import detect, DetectorFactory
72
+
73
+ # Set random seed for consistent language detection results
74
+ DetectorFactory.seed = 0
75
+
76
+
77
+ def is_english_text(text):
78
+ """
79
+ Detect if the given text is in English.
80
+ Returns True if English, False otherwise.
81
+ """
82
+ try:
83
+ # Remove leading/trailing whitespace and check if text is not empty
84
+ text = text.strip()
85
+ if not text:
86
+ return False
87
+
88
+ # Detect language
89
+ detected_language = detect(text)
90
+ return detected_language == 'en'
91
+ except Exception:
92
+ # If detection fails, assume it's not English for safety
93
+ return False
94
+
95
+
96
+ def get_client_ip():
97
+ """Get the client's IP address, handling proxies and load balancers."""
98
+ # Check for forwarded headers first (common with reverse proxies)
99
+ if request.headers.get('X-Forwarded-For'):
100
+ # X-Forwarded-For can contain multiple IPs, take the first one
101
+ return request.headers.get('X-Forwarded-For').split(',')[0].strip()
102
+ elif request.headers.get('X-Real-IP'):
103
+ return request.headers.get('X-Real-IP')
104
+ elif request.headers.get('CF-Connecting-IP'): # Cloudflare
105
+ return request.headers.get('CF-Connecting-IP')
106
+ else:
107
+ return request.remote_addr
108
+
109
+
110
+ # Load environment variables
111
+ if not IS_SPACES:
112
+ load_dotenv() # Only load .env if not running in a Hugging Face Space
113
+
114
+ app = Flask(__name__)
115
+ app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", os.urandom(24))
116
+ app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv(
117
+ "DATABASE_URI", "sqlite:///tts_arena.db"
118
+ )
119
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
120
+ app.config["SESSION_COOKIE_SECURE"] = True
121
+ app.config["SESSION_COOKIE_SAMESITE"] = (
122
+ "None" if IS_SPACES else "Lax"
123
+ ) # HF Spaces uses iframes to load the app, so we need to set SAMESITE to None
124
+ app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) # Set to desired duration
125
+
126
+ # Force HTTPS when running in HuggingFace Spaces
127
+ if IS_SPACES:
128
+ app.config["PREFERRED_URL_SCHEME"] = "https"
129
+
130
+ # Cloudflare Turnstile settings
131
+ app.config["TURNSTILE_ENABLED"] = (
132
+ os.getenv("TURNSTILE_ENABLED", "False").lower() == "true"
133
+ )
134
+ app.config["TURNSTILE_SITE_KEY"] = os.getenv("TURNSTILE_SITE_KEY", "")
135
+ app.config["TURNSTILE_SECRET_KEY"] = os.getenv("TURNSTILE_SECRET_KEY", "")
136
+ app.config["TURNSTILE_VERIFY_URL"] = (
137
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify"
138
+ )
139
+
140
+ migrate = Migrate(app, db)
141
+
142
+ # Initialize extensions
143
+ db.init_app(app)
144
+ login_manager = LoginManager()
145
+ login_manager.init_app(app)
146
+ login_manager.login_view = "auth.login"
147
+
148
+ # Initialize OAuth
149
+ init_oauth(app)
150
+
151
+ # Configure rate limits
152
+ limiter = Limiter(
153
+ app=app,
154
+ key_func=get_remote_address,
155
+ default_limits=["2000 per day", "50 per minute"],
156
+ storage_uri="memory://",
157
+ )
158
+
159
+ # TTS Cache Configuration - Read from environment
160
+ TTS_CACHE_SIZE = int(os.getenv("TTS_CACHE_SIZE", "10"))
161
+ CACHE_AUDIO_SUBDIR = "cache"
162
+ tts_cache = {} # sentence -> {model_a, model_b, audio_a, audio_b, created_at}
163
+ tts_cache_lock = threading.Lock()
164
+ SMOOTHING_FACTOR_MODEL_SELECTION = 500 # For weighted random model selection
165
+ # Increased max_workers to 8 for concurrent generation/refill
166
+ cache_executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix='CacheReplacer')
167
+ all_harvard_sentences = [] # Keep the full list available
168
+
169
+ # Create temp directories
170
+ TEMP_AUDIO_DIR = os.path.join(tempfile.gettempdir(), "tts_arena_audio")
171
+ CACHE_AUDIO_DIR = os.path.join(TEMP_AUDIO_DIR, CACHE_AUDIO_SUBDIR)
172
+ os.makedirs(TEMP_AUDIO_DIR, exist_ok=True)
173
+ os.makedirs(CACHE_AUDIO_DIR, exist_ok=True) # Ensure cache subdir exists
174
+
175
+
176
+ # Store active TTS sessions
177
+ app.tts_sessions = {}
178
+ tts_sessions = app.tts_sessions
179
+
180
+ # Store active conversational sessions
181
+ app.conversational_sessions = {}
182
+ conversational_sessions = app.conversational_sessions
183
+
184
+ # Register blueprints
185
+ app.register_blueprint(auth, url_prefix="/auth")
186
+ app.register_blueprint(admin)
187
+
188
+
189
+ @login_manager.user_loader
190
+ def load_user(user_id):
191
+ return User.query.get(int(user_id))
192
+
193
+
194
+ @app.before_request
195
+ def before_request():
196
+ g.user = current_user
197
+ g.is_admin = is_admin(current_user)
198
+
199
+ # Ensure HTTPS for HuggingFace Spaces environment
200
+ if IS_SPACES and request.headers.get("X-Forwarded-Proto") == "http":
201
+ url = request.url.replace("http://", "https://", 1)
202
+ return redirect(url, code=301)
203
+
204
+ # Check if Turnstile verification is required
205
+ if app.config["TURNSTILE_ENABLED"]:
206
+ # Exclude verification routes
207
+ excluded_routes = ["verify_turnstile", "turnstile_page", "static"]
208
+ if request.endpoint not in excluded_routes:
209
+ # Check if user is verified
210
+ if not session.get("turnstile_verified"):
211
+ # Save original URL for redirect after verification
212
+ redirect_url = request.url
213
+ # Force HTTPS in HuggingFace Spaces
214
+ if IS_SPACES and redirect_url.startswith("http://"):
215
+ redirect_url = redirect_url.replace("http://", "https://", 1)
216
+
217
+ # If it's an API request, return a JSON response
218
+ if request.path.startswith("/api/"):
219
+ return jsonify({"error": "Turnstile verification required"}), 403
220
+ # For regular requests, redirect to verification page
221
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
222
+ else:
223
+ # Check if verification has expired (default: 24 hours)
224
+ verification_timeout = (
225
+ int(os.getenv("TURNSTILE_TIMEOUT_HOURS", "24")) * 3600
226
+ ) # Convert hours to seconds
227
+ verified_at = session.get("turnstile_verified_at", 0)
228
+ current_time = datetime.utcnow().timestamp()
229
+
230
+ if current_time - verified_at > verification_timeout:
231
+ # Verification expired, clear status and redirect to verification page
232
+ session.pop("turnstile_verified", None)
233
+ session.pop("turnstile_verified_at", None)
234
+
235
+ redirect_url = request.url
236
+ # Force HTTPS in HuggingFace Spaces
237
+ if IS_SPACES and redirect_url.startswith("http://"):
238
+ redirect_url = redirect_url.replace("http://", "https://", 1)
239
+
240
+ if request.path.startswith("/api/"):
241
+ return jsonify({"error": "Turnstile verification expired"}), 403
242
+ return redirect(
243
+ url_for("turnstile_page", redirect_url=redirect_url)
244
+ )
245
+
246
+
247
+ @app.route("/turnstile", methods=["GET"])
248
+ def turnstile_page():
249
+ """Display Cloudflare Turnstile verification page"""
250
+ redirect_url = request.args.get("redirect_url", url_for("arena", _external=True))
251
+
252
+ # Force HTTPS in HuggingFace Spaces
253
+ if IS_SPACES and redirect_url.startswith("http://"):
254
+ redirect_url = redirect_url.replace("http://", "https://", 1)
255
+
256
+ return render_template(
257
+ "turnstile.html",
258
+ turnstile_site_key=app.config["TURNSTILE_SITE_KEY"],
259
+ redirect_url=redirect_url,
260
+ )
261
+
262
+
263
+ @app.route("/verify-turnstile", methods=["POST"])
264
+ def verify_turnstile():
265
+ """Verify Cloudflare Turnstile token"""
266
+ token = request.form.get("cf-turnstile-response")
267
+ redirect_url = request.form.get("redirect_url", url_for("arena", _external=True))
268
+
269
+ # Force HTTPS in HuggingFace Spaces
270
+ if IS_SPACES and redirect_url.startswith("http://"):
271
+ redirect_url = redirect_url.replace("http://", "https://", 1)
272
+
273
+ if not token:
274
+ # If AJAX request, return JSON error
275
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
276
+ return (
277
+ jsonify({"success": False, "error": "Missing verification token"}),
278
+ 400,
279
+ )
280
+ # Otherwise redirect back to turnstile page
281
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
282
+
283
+ # Verify token with Cloudflare
284
+ data = {
285
+ "secret": app.config["TURNSTILE_SECRET_KEY"],
286
+ "response": token,
287
+ "remoteip": request.remote_addr,
288
+ }
289
+
290
+ try:
291
+ response = requests.post(app.config["TURNSTILE_VERIFY_URL"], data=data)
292
+ result = response.json()
293
+
294
+ if result.get("success"):
295
+ # Set verification status in session
296
+ session["turnstile_verified"] = True
297
+ session["turnstile_verified_at"] = datetime.utcnow().timestamp()
298
+
299
+ # Determine response type based on request
300
+ is_xhr = request.headers.get("X-Requested-With") == "XMLHttpRequest"
301
+ accepts_json = "application/json" in request.headers.get("Accept", "")
302
+
303
+ # If AJAX or JSON request, return success JSON
304
+ if is_xhr or accepts_json:
305
+ return jsonify({"success": True, "redirect": redirect_url})
306
+
307
+ # For regular form submissions, redirect to the target URL
308
+ return redirect(redirect_url)
309
+ else:
310
+ # Verification failed
311
+ app.logger.warning(f"Turnstile verification failed: {result}")
312
+
313
+ # If AJAX request, return JSON error
314
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
315
+ return jsonify({"success": False, "error": "Verification failed"}), 403
316
+
317
+ # Otherwise redirect back to turnstile page
318
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
319
+
320
+ except Exception as e:
321
+ app.logger.error(f"Turnstile verification error: {str(e)}")
322
+
323
+ # If AJAX request, return JSON error
324
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
325
+ return (
326
+ jsonify(
327
+ {"success": False, "error": "Server error during verification"}
328
+ ),
329
+ 500,
330
+ )
331
+
332
+ # Otherwise redirect back to turnstile page
333
+ return redirect(url_for("turnstile_page", redirect_url=redirect_url))
334
+
335
+ # Load sentences from the TTS-AGI/arena-prompts dataset
336
+ print("Loading TTS-AGI/arena-prompts dataset...")
337
+ dataset = load_dataset("TTS-AGI/arena-prompts", split="train")
338
+ # Extract the text column and clean up
339
+ all_harvard_sentences = [item['text'].strip() for item in dataset if item['text'] and item['text'].strip()]
340
+ print(f"Loaded {len(all_harvard_sentences)} sentences from dataset")
341
+
342
+ # Initialize initial_sentences as empty - will be populated with unconsumed sentences only
343
+ initial_sentences = []
344
+
345
+ @app.route("/")
346
+ def arena():
347
+ # Pass a subset of sentences for the random button fallback
348
+ return render_template("arena.html", harvard_sentences=json.dumps(initial_sentences))
349
+
350
+
351
+ @app.route("/leaderboard")
352
+ def leaderboard():
353
+ tts_leaderboard = get_leaderboard_data(ModelType.TTS)
354
+ conversational_leaderboard = get_leaderboard_data(ModelType.CONVERSATIONAL)
355
+ top_voters = get_top_voters(10) # Get top 10 voters
356
+
357
+ # Initialize personal leaderboard data
358
+ tts_personal_leaderboard = None
359
+ conversational_personal_leaderboard = None
360
+ user_leaderboard_visibility = None
361
+
362
+ # If user is logged in, get their personal leaderboard and visibility setting
363
+ if current_user.is_authenticated:
364
+ tts_personal_leaderboard = get_user_leaderboard(current_user.id, ModelType.TTS)
365
+ conversational_personal_leaderboard = get_user_leaderboard(
366
+ current_user.id, ModelType.CONVERSATIONAL
367
+ )
368
+ user_leaderboard_visibility = current_user.show_in_leaderboard
369
+
370
+ # Get key dates for the timeline
371
+ tts_key_dates = get_key_historical_dates(ModelType.TTS)
372
+ conversational_key_dates = get_key_historical_dates(ModelType.CONVERSATIONAL)
373
+
374
+ # Format dates for display in the dropdown
375
+ formatted_tts_dates = [date.strftime("%B %Y") for date in tts_key_dates]
376
+ formatted_conversational_dates = [
377
+ date.strftime("%B %Y") for date in conversational_key_dates
378
+ ]
379
+
380
+ return render_template(
381
+ "leaderboard.html",
382
+ tts_leaderboard=tts_leaderboard,
383
+ conversational_leaderboard=conversational_leaderboard,
384
+ tts_personal_leaderboard=tts_personal_leaderboard,
385
+ conversational_personal_leaderboard=conversational_personal_leaderboard,
386
+ tts_key_dates=tts_key_dates,
387
+ conversational_key_dates=conversational_key_dates,
388
+ formatted_tts_dates=formatted_tts_dates,
389
+ formatted_conversational_dates=formatted_conversational_dates,
390
+ top_voters=top_voters,
391
+ user_leaderboard_visibility=user_leaderboard_visibility
392
+ )
393
+
394
+
395
+ @app.route("/api/historical-leaderboard/<model_type>")
396
+ def historical_leaderboard(model_type):
397
+ """Get historical leaderboard data for a specific date"""
398
+ if model_type not in [ModelType.TTS, ModelType.CONVERSATIONAL]:
399
+ return jsonify({"error": "Invalid model type"}), 400
400
+
401
+ # Get date from query parameter
402
+ date_str = request.args.get("date")
403
+ if not date_str:
404
+ return jsonify({"error": "Date parameter is required"}), 400
405
+
406
+ try:
407
+ # Parse date from URL parameter (format: YYYY-MM-DD)
408
+ target_date = datetime.strptime(date_str, "%Y-%m-%d")
409
+
410
+ # Get historical leaderboard data
411
+ leaderboard_data = get_historical_leaderboard_data(model_type, target_date)
412
+
413
+ return jsonify(
414
+ {"date": target_date.strftime("%B %d, %Y"), "leaderboard": leaderboard_data}
415
+ )
416
+ except ValueError:
417
+ return jsonify({"error": "Invalid date format. Use YYYY-MM-DD"}), 400
418
+
419
+
420
+ @app.route("/about")
421
+ def about():
422
+ return render_template("about.html")
423
+
424
+
425
+ # --- TTS Caching Functions ---
426
+
427
+ def generate_and_save_tts(text, model_id, output_dir):
428
+ """Generates TTS and saves it to a specific directory, returning the full path."""
429
+ temp_audio_path = None # Initialize to None
430
+ try:
431
+ app.logger.debug(f"[TTS Gen {model_id}] Starting generation for: '{text[:30]}...'")
432
+ # If predict_tts saves file itself and returns path:
433
+ temp_audio_path = predict_tts(text, model_id)
434
+ app.logger.debug(f"[TTS Gen {model_id}] predict_tts returned: {temp_audio_path}")
435
+
436
+ if not temp_audio_path or not os.path.exists(temp_audio_path):
437
+ app.logger.warning(f"[TTS Gen {model_id}] predict_tts failed or returned invalid path: {temp_audio_path}")
438
+ raise ValueError("predict_tts did not return a valid path or file does not exist")
439
+
440
+ file_uuid = str(uuid.uuid4())
441
+ dest_path = os.path.join(output_dir, f"{file_uuid}.wav")
442
+ app.logger.debug(f"[TTS Gen {model_id}] Moving {temp_audio_path} to {dest_path}")
443
+ # Move the file generated by predict_tts to the target cache directory
444
+ shutil.move(temp_audio_path, dest_path)
445
+ app.logger.debug(f"[TTS Gen {model_id}] Move successful. Returning {dest_path}")
446
+ return dest_path
447
+
448
+ except Exception as e:
449
+ app.logger.error(f"Error generating/saving TTS for model {model_id} and text '{text[:30]}...': {str(e)}")
450
+ # Ensure temporary file from predict_tts (if any) is cleaned up
451
+ if temp_audio_path and os.path.exists(temp_audio_path):
452
+ try:
453
+ app.logger.debug(f"[TTS Gen {model_id}] Cleaning up temporary file {temp_audio_path} after error.")
454
+ os.remove(temp_audio_path)
455
+ except OSError:
456
+ pass # Ignore error if file couldn't be removed
457
+ return None
458
+
459
+
460
+ def _generate_cache_entry_task(sentence):
461
+ """Task function to generate audio for a sentence and add to cache."""
462
+ # Wrap the entire task in an application context
463
+ with app.app_context():
464
+ if not sentence:
465
+ # Select a new sentence if not provided (for replacement)
466
+ with tts_cache_lock:
467
+ cached_keys = set(tts_cache.keys())
468
+ # Get unconsumed sentences that are also not already cached
469
+ unconsumed_sentences = get_unconsumed_sentences(all_harvard_sentences)
470
+ available_sentences = [s for s in unconsumed_sentences if s not in cached_keys]
471
+ if not available_sentences:
472
+ app.logger.warning("No more unconsumed sentences available for caching. All sentences have been consumed.")
473
+ return
474
+ sentence = random.choice(available_sentences)
475
+
476
+ # app.logger.info removed duplicate log
477
+ print(f"[Cache Task] Querying models for: '{sentence[:50]}...'")
478
+ available_models = Model.query.filter_by(
479
+ model_type=ModelType.TTS, is_active=True
480
+ ).all()
481
+
482
+ if len(available_models) < 2:
483
+ app.logger.error("Not enough active TTS models to generate cache entry.")
484
+ return
485
+
486
+ try:
487
+ models = get_weighted_random_models(available_models, 2, ModelType.TTS)
488
+ model_a_id = models[0].id
489
+ model_b_id = models[1].id
490
+
491
+ # Generate audio concurrently using a local executor for clarity within the task
492
+ with ThreadPoolExecutor(max_workers=2, thread_name_prefix='AudioGen') as audio_executor:
493
+ future_a = audio_executor.submit(generate_and_save_tts, sentence, model_a_id, CACHE_AUDIO_DIR)
494
+ future_b = audio_executor.submit(generate_and_save_tts, sentence, model_b_id, CACHE_AUDIO_DIR)
495
+
496
+ timeout_seconds = 120
497
+ audio_a_path = future_a.result(timeout=timeout_seconds)
498
+ audio_b_path = future_b.result(timeout=timeout_seconds)
499
+
500
+ if audio_a_path and audio_b_path:
501
+ with tts_cache_lock:
502
+ # Only add if the sentence isn't already back in the cache
503
+ # And ensure cache size doesn't exceed limit
504
+ if sentence not in tts_cache and len(tts_cache) < TTS_CACHE_SIZE:
505
+ tts_cache[sentence] = {
506
+ "model_a": model_a_id,
507
+ "model_b": model_b_id,
508
+ "audio_a": audio_a_path,
509
+ "audio_b": audio_b_path,
510
+ "created_at": datetime.utcnow(),
511
+ }
512
+ # Mark sentence as consumed for cache usage
513
+ mark_sentence_consumed(sentence, usage_type='cache')
514
+ app.logger.info(f"Successfully cached entry for: '{sentence[:50]}...'")
515
+ elif sentence in tts_cache:
516
+ app.logger.warning(f"Sentence '{sentence[:50]}...' already re-cached. Discarding new generation.")
517
+ # Clean up the newly generated files if not added
518
+ if os.path.exists(audio_a_path): os.remove(audio_a_path)
519
+ if os.path.exists(audio_b_path): os.remove(audio_b_path)
520
+ else: # Cache is full
521
+ app.logger.warning(f"Cache is full ({len(tts_cache)} entries). Discarding new generation for '{sentence[:50]}...'.")
522
+ # Clean up the newly generated files if not added
523
+ if os.path.exists(audio_a_path): os.remove(audio_a_path)
524
+ if os.path.exists(audio_b_path): os.remove(audio_b_path)
525
+
526
+ else:
527
+ app.logger.error(f"Failed to generate one or both audio files for cache: '{sentence[:50]}...'")
528
+ # Clean up whichever file might have been created
529
+ if audio_a_path and os.path.exists(audio_a_path): os.remove(audio_a_path)
530
+ if audio_b_path and os.path.exists(audio_b_path): os.remove(audio_b_path)
531
+
532
+ except Exception as e:
533
+ # Log the exception within the app context
534
+ app.logger.error(f"Exception in _generate_cache_entry_task for '{sentence[:50]}...': {str(e)}", exc_info=True)
535
+
536
+
537
+ def update_initial_sentences():
538
+ """Update initial sentences to only include unconsumed ones."""
539
+ global initial_sentences
540
+ try:
541
+ unconsumed_for_initial = get_unconsumed_sentences(all_harvard_sentences)
542
+ if unconsumed_for_initial:
543
+ initial_sentences = random.sample(unconsumed_for_initial, min(len(unconsumed_for_initial), 500))
544
+ print(f"Updated initial sentences with {len(initial_sentences)} unconsumed sentences")
545
+ else:
546
+ print("Warning: No unconsumed sentences available for initial selection, disabling fallback")
547
+ initial_sentences = [] # No fallback to consumed sentences
548
+ except Exception as e:
549
+ print(f"Error updating initial sentences: {e}, disabling fallback for security")
550
+ initial_sentences = [] # No fallback to consumed sentences
551
+
552
+
553
+ def initialize_tts_cache():
554
+ print("Initializing TTS cache")
555
+ """Selects initial sentences and starts generation tasks."""
556
+ with app.app_context(): # Ensure access to models
557
+ if not all_harvard_sentences:
558
+ app.logger.error("Harvard sentences not loaded. Cannot initialize cache.")
559
+ return
560
+
561
+ # Update initial sentences with unconsumed ones
562
+ update_initial_sentences()
563
+
564
+ # Only use unconsumed sentences for initial cache population
565
+ unconsumed_sentences = get_unconsumed_sentences(all_harvard_sentences)
566
+ if not unconsumed_sentences:
567
+ app.logger.error("No unconsumed sentences available for cache initialization. Cache will remain empty.")
568
+ app.logger.warning("WARNING: All sentences from the dataset have been consumed. No new TTS generations will be possible.")
569
+ return
570
+ initial_selection = random.sample(unconsumed_sentences, min(len(unconsumed_sentences), TTS_CACHE_SIZE))
571
+ app.logger.info(f"Initializing TTS cache with {len(initial_selection)} sentences...")
572
+
573
+ for sentence in initial_selection:
574
+ # Use the main cache_executor for initial population too
575
+ cache_executor.submit(_generate_cache_entry_task, sentence)
576
+ app.logger.info("Submitted initial cache generation tasks.")
577
+
578
+ # --- End TTS Caching Functions ---
579
+
580
+
581
+ @app.route("/api/tts/generate", methods=["POST"])
582
+ @limiter.limit("10 per minute") # Keep limit, cached responses are still requests
583
+ def generate_tts():
584
+ # If verification not setup, handle it first
585
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
586
+ return jsonify({"error": "Turnstile verification required"}), 403
587
+
588
+ # Require user to be logged in to generate audio
589
+ if not current_user.is_authenticated:
590
+ return jsonify({"error": "You must be logged in to generate audio"}), 401
591
+
592
+ data = request.json
593
+ text = data.get("text", "").strip() # Ensure text is stripped
594
+
595
+ if not text or len(text) > 1000:
596
+ return jsonify({"error": "Invalid or too long text"}), 400
597
+
598
+ # Check if text is in English
599
+ if not is_english_text(text):
600
+ return jsonify({"error": "Only English language text is supported for now. Please provide text in English. A multilingual Arena is coming soon!"}), 400
601
+
602
+ # Check if sentence has already been consumed
603
+ if is_sentence_consumed(text):
604
+ remaining_count = len(get_unconsumed_sentences(all_harvard_sentences))
605
+ if remaining_count == 0:
606
+ return jsonify({"error": "This sentence has already been used and no unconsumed sentences remain. All sentences from the dataset have been consumed."}), 400
607
+ else:
608
+ return jsonify({"error": f"This sentence has already been used. Please select a different sentence. {remaining_count} sentences remain available."}), 400
609
+
610
+ # --- Cache Check ---
611
+ cache_hit = False
612
+ session_data_from_cache = None
613
+ with tts_cache_lock:
614
+ if text in tts_cache:
615
+ cache_hit = True
616
+ cached_entry = tts_cache.pop(text) # Remove from cache immediately
617
+ app.logger.info(f"TTS Cache HIT for: '{text[:50]}...'")
618
+
619
+ # Prepare session data using cached info
620
+ session_id = str(uuid.uuid4())
621
+ session_data_from_cache = {
622
+ "model_a": cached_entry["model_a"],
623
+ "model_b": cached_entry["model_b"],
624
+ "audio_a": cached_entry["audio_a"], # Paths are now from cache_dir
625
+ "audio_b": cached_entry["audio_b"],
626
+ "text": text,
627
+ "created_at": datetime.utcnow(),
628
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
629
+ "voted": False,
630
+ "cache_hit": True,
631
+ }
632
+ app.tts_sessions[session_id] = session_data_from_cache
633
+
634
+ # Note: Sentence was already marked as consumed when it was cached
635
+ # No need to mark it again here
636
+
637
+ # --- Trigger background tasks to refill the cache ---
638
+ # Calculate how many slots need refilling
639
+ current_cache_size = len(tts_cache) # Size *before* adding potentially new items
640
+ needed_refills = TTS_CACHE_SIZE - current_cache_size
641
+ # Limit concurrent refills to 8 or the actual need
642
+ refills_to_submit = min(needed_refills, 8)
643
+
644
+ if refills_to_submit > 0:
645
+ app.logger.info(f"Cache hit: Submitting {refills_to_submit} background task(s) to refill cache (current size: {current_cache_size}, target: {TTS_CACHE_SIZE}).")
646
+ for _ in range(refills_to_submit):
647
+ # Pass None to signal replacement selection within the task
648
+ cache_executor.submit(_generate_cache_entry_task, None)
649
+ else:
650
+ app.logger.info(f"Cache hit: Cache is already full or at target size ({current_cache_size}/{TTS_CACHE_SIZE}). No refill tasks submitted.")
651
+ # --- End Refill Trigger ---
652
+
653
+ if cache_hit and session_data_from_cache:
654
+ # Return response using cached data
655
+ # Note: The files are now managed by the session lifecycle (cleanup_session)
656
+ return jsonify(
657
+ {
658
+ "session_id": session_id,
659
+ "audio_a": f"/api/tts/audio/{session_id}/a",
660
+ "audio_b": f"/api/tts/audio/{session_id}/b",
661
+ "expires_in": 1800, # 30 minutes in seconds
662
+ "cache_hit": True,
663
+ }
664
+ )
665
+ # --- End Cache Check ---
666
+
667
+ # --- Cache Miss: Generate on the fly ---
668
+ app.logger.info(f"TTS Cache MISS for: '{text[:50]}...'. Generating on the fly.")
669
+ available_models = Model.query.filter_by(
670
+ model_type=ModelType.TTS, is_active=True
671
+ ).all()
672
+ if len(available_models) < 2:
673
+ return jsonify({"error": "Not enough TTS models available"}), 500
674
+
675
+ selected_models = get_weighted_random_models(available_models, 2, ModelType.TTS)
676
+
677
+ try:
678
+ audio_files = []
679
+ model_ids = []
680
+
681
+ # Function to process a single model (generate directly to TEMP_AUDIO_DIR, not cache subdir)
682
+ def process_model_on_the_fly(model):
683
+ # Generate and save directly to the main temp dir
684
+ # Assume predict_tts handles saving temporary files
685
+ temp_audio_path = predict_tts(text, model.id)
686
+ if not temp_audio_path or not os.path.exists(temp_audio_path):
687
+ raise ValueError(f"predict_tts failed for model {model.id}")
688
+
689
+ # Create a unique name in the main TEMP_AUDIO_DIR for the session
690
+ file_uuid = str(uuid.uuid4())
691
+ dest_path = os.path.join(TEMP_AUDIO_DIR, f"{file_uuid}.wav")
692
+ shutil.move(temp_audio_path, dest_path) # Move from predict_tts's temp location
693
+
694
+ return {"model_id": model.id, "audio_path": dest_path}
695
+
696
+
697
+ # Use ThreadPoolExecutor to process models concurrently
698
+ with ThreadPoolExecutor(max_workers=2) as executor:
699
+ results = list(executor.map(process_model_on_the_fly, selected_models))
700
+
701
+ # Extract results
702
+ for result in results:
703
+ model_ids.append(result["model_id"])
704
+ audio_files.append(result["audio_path"])
705
+
706
+ # Create session
707
+ session_id = str(uuid.uuid4())
708
+ app.tts_sessions[session_id] = {
709
+ "model_a": model_ids[0],
710
+ "model_b": model_ids[1],
711
+ "audio_a": audio_files[0], # Paths are now from TEMP_AUDIO_DIR directly
712
+ "audio_b": audio_files[1],
713
+ "text": text,
714
+ "created_at": datetime.utcnow(),
715
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
716
+ "voted": False,
717
+ "cache_hit": False,
718
+ }
719
+
720
+ # Don't mark as consumed yet - wait until vote is submitted to maintain security
721
+ # while allowing legitimate votes to count for ELO
722
+
723
+ # Return audio file paths and session
724
+ return jsonify(
725
+ {
726
+ "session_id": session_id,
727
+ "audio_a": f"/api/tts/audio/{session_id}/a",
728
+ "audio_b": f"/api/tts/audio/{session_id}/b",
729
+ "expires_in": 1800,
730
+ "cache_hit": False,
731
+ }
732
+ )
733
+
734
+ except Exception as e:
735
+ app.logger.error(f"TTS on-the-fly generation error: {str(e)}", exc_info=True)
736
+ # Cleanup any files potentially created during the failed attempt
737
+ if 'results' in locals():
738
+ for res in results:
739
+ if 'audio_path' in res and os.path.exists(res['audio_path']):
740
+ try:
741
+ os.remove(res['audio_path'])
742
+ except OSError:
743
+ pass
744
+ return jsonify({"error": "Failed to generate TTS"}), 500
745
+ # --- End Cache Miss ---
746
+
747
+
748
+ @app.route("/api/tts/audio/<session_id>/<model_key>")
749
+ def get_audio(session_id, model_key):
750
+ # If verification not setup, handle it first
751
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
752
+ return jsonify({"error": "Turnstile verification required"}), 403
753
+
754
+ if session_id not in app.tts_sessions:
755
+ return jsonify({"error": "Invalid or expired session"}), 404
756
+
757
+ session_data = app.tts_sessions[session_id]
758
+
759
+ # Check if session expired
760
+ if datetime.utcnow() > session_data["expires_at"]:
761
+ cleanup_session(session_id)
762
+ return jsonify({"error": "Session expired"}), 410
763
+
764
+ if model_key == "a":
765
+ audio_path = session_data["audio_a"]
766
+ elif model_key == "b":
767
+ audio_path = session_data["audio_b"]
768
+ else:
769
+ return jsonify({"error": "Invalid model key"}), 400
770
+
771
+ # Check if file exists
772
+ if not os.path.exists(audio_path):
773
+ return jsonify({"error": "Audio file not found"}), 404
774
+
775
+ return send_file(audio_path, mimetype="audio/wav")
776
+
777
+
778
+ @app.route("/api/tts/vote", methods=["POST"])
779
+ @limiter.limit("30 per minute")
780
+ def submit_vote():
781
+ # If verification not setup, handle it first
782
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
783
+ return jsonify({"error": "Turnstile verification required"}), 403
784
+
785
+ # Require user to be logged in to vote
786
+ if not current_user.is_authenticated:
787
+ return jsonify({"error": "You must be logged in to vote"}), 401
788
+
789
+ # Security checks for vote manipulation prevention
790
+ client_ip = get_client_ip()
791
+ vote_allowed, security_reason, security_score = is_vote_allowed(current_user.id, client_ip)
792
+
793
+ if not vote_allowed:
794
+ app.logger.warning(f"Vote blocked for user {current_user.username} (ID: {current_user.id}): {security_reason} (Score: {security_score})")
795
+ return jsonify({"error": f"Vote not allowed: {security_reason}"}), 403
796
+
797
+ data = request.json
798
+ session_id = data.get("session_id")
799
+ chosen_model_key = data.get("chosen_model") # "a" or "b"
800
+
801
+ if not session_id or session_id not in app.tts_sessions:
802
+ return jsonify({"error": "Invalid or expired session"}), 404
803
+
804
+ if not chosen_model_key or chosen_model_key not in ["a", "b"]:
805
+ return jsonify({"error": "Invalid chosen model"}), 400
806
+
807
+ session_data = app.tts_sessions[session_id]
808
+
809
+ # Check if session expired
810
+ if datetime.utcnow() > session_data["expires_at"]:
811
+ cleanup_session(session_id)
812
+ return jsonify({"error": "Session expired"}), 410
813
+
814
+ # Check if already voted
815
+ if session_data["voted"]:
816
+ return jsonify({"error": "Vote already submitted for this session"}), 400
817
+
818
+ # Get model IDs and audio paths
819
+ chosen_id = (
820
+ session_data["model_a"] if chosen_model_key == "a" else session_data["model_b"]
821
+ )
822
+ rejected_id = (
823
+ session_data["model_b"] if chosen_model_key == "a" else session_data["model_a"]
824
+ )
825
+ chosen_audio_path = (
826
+ session_data["audio_a"] if chosen_model_key == "a" else session_data["audio_b"]
827
+ )
828
+ rejected_audio_path = (
829
+ session_data["audio_b"] if chosen_model_key == "a" else session_data["audio_a"]
830
+ )
831
+
832
+ # Calculate session duration and gather analytics data
833
+ vote_time = datetime.utcnow()
834
+ session_duration = (vote_time - session_data["created_at"]).total_seconds()
835
+ client_ip = get_client_ip()
836
+ user_agent = request.headers.get('User-Agent')
837
+ cache_hit = session_data.get("cache_hit", False)
838
+
839
+ # Record vote in database with analytics data
840
+ vote, error = record_vote(
841
+ current_user.id,
842
+ session_data["text"],
843
+ chosen_id,
844
+ rejected_id,
845
+ ModelType.TTS,
846
+ session_duration=session_duration,
847
+ ip_address=client_ip,
848
+ user_agent=user_agent,
849
+ generation_date=session_data["created_at"],
850
+ cache_hit=cache_hit,
851
+ all_dataset_sentences=all_harvard_sentences
852
+ )
853
+
854
+ if error:
855
+ return jsonify({"error": error}), 500
856
+
857
+ # Sentence consumption is now handled within record_vote function
858
+
859
+ # --- Save preference data ---
860
+ try:
861
+ vote_uuid = str(uuid.uuid4())
862
+ vote_dir = os.path.join("./votes", vote_uuid)
863
+ os.makedirs(vote_dir, exist_ok=True)
864
+
865
+ # Copy audio files
866
+ shutil.copy(chosen_audio_path, os.path.join(vote_dir, "chosen.wav"))
867
+ shutil.copy(rejected_audio_path, os.path.join(vote_dir, "rejected.wav"))
868
+
869
+ # Create metadata
870
+ chosen_model_obj = Model.query.get(chosen_id)
871
+ rejected_model_obj = Model.query.get(rejected_id)
872
+ metadata = {
873
+ "text": session_data["text"],
874
+ "chosen_model": chosen_model_obj.name if chosen_model_obj else "Unknown",
875
+ "chosen_model_id": chosen_model_obj.id if chosen_model_obj else "Unknown",
876
+ "rejected_model": rejected_model_obj.name if rejected_model_obj else "Unknown",
877
+ "rejected_model_id": rejected_model_obj.id if rejected_model_obj else "Unknown",
878
+ "session_id": session_id,
879
+ "timestamp": datetime.utcnow().isoformat(),
880
+ "username": current_user.username,
881
+ "model_type": "TTS"
882
+ }
883
+ with open(os.path.join(vote_dir, "metadata.json"), "w") as f:
884
+ json.dump(metadata, f, indent=2)
885
+
886
+ except Exception as e:
887
+ app.logger.error(f"Error saving preference data for vote {session_id}: {str(e)}")
888
+ # Continue even if saving preference data fails, vote is already recorded
889
+
890
+ # Mark session as voted
891
+ session_data["voted"] = True
892
+
893
+ # Check for coordinated voting campaigns (async to not slow down response)
894
+ try:
895
+ from threading import Thread
896
+ campaign_check_thread = Thread(target=check_for_coordinated_campaigns)
897
+ campaign_check_thread.daemon = True
898
+ campaign_check_thread.start()
899
+ except Exception as e:
900
+ app.logger.error(f"Error starting coordinated campaign check thread: {str(e)}")
901
+
902
+ # Return updated models (use previously fetched objects)
903
+ return jsonify(
904
+ {
905
+ "success": True,
906
+ "chosen_model": {"id": chosen_id, "name": chosen_model_obj.name if chosen_model_obj else "Unknown"},
907
+ "rejected_model": {
908
+ "id": rejected_id,
909
+ "name": rejected_model_obj.name if rejected_model_obj else "Unknown",
910
+ },
911
+ "names": {
912
+ "a": (
913
+ chosen_model_obj.name if chosen_model_key == "a" else rejected_model_obj.name
914
+ if chosen_model_obj and rejected_model_obj else "Unknown"
915
+ ),
916
+ "b": (
917
+ rejected_model_obj.name if chosen_model_key == "a" else chosen_model_obj.name
918
+ if chosen_model_obj and rejected_model_obj else "Unknown"
919
+ ),
920
+ },
921
+ }
922
+ )
923
+
924
+
925
+ def cleanup_session(session_id):
926
+ """Remove session and its audio files"""
927
+ if session_id in app.tts_sessions:
928
+ session = app.tts_sessions[session_id]
929
+
930
+ # Remove audio files
931
+ for audio_file in [session["audio_a"], session["audio_b"]]:
932
+ if os.path.exists(audio_file):
933
+ try:
934
+ os.remove(audio_file)
935
+ except Exception as e:
936
+ app.logger.error(f"Error removing audio file: {str(e)}")
937
+
938
+ # Remove session
939
+ del app.tts_sessions[session_id]
940
+
941
+
942
+ @app.route("/api/conversational/generate", methods=["POST"])
943
+ @limiter.limit("5 per minute")
944
+ def generate_podcast():
945
+ # If verification not setup, handle it first
946
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
947
+ return jsonify({"error": "Turnstile verification required"}), 403
948
+
949
+ # Require user to be logged in to generate audio
950
+ if not current_user.is_authenticated:
951
+ return jsonify({"error": "You must be logged in to generate audio"}), 401
952
+
953
+ data = request.json
954
+ script = data.get("script")
955
+
956
+ if not script or not isinstance(script, list) or len(script) < 2:
957
+ return jsonify({"error": "Invalid script format or too short"}), 400
958
+
959
+ # Validate script format
960
+ for line in script:
961
+ if not isinstance(line, dict) or "text" not in line or "speaker_id" not in line:
962
+ return (
963
+ jsonify(
964
+ {
965
+ "error": "Invalid script line format. Each line must have text and speaker_id"
966
+ }
967
+ ),
968
+ 400,
969
+ )
970
+ if (
971
+ not line["text"]
972
+ or not isinstance(line["speaker_id"], int)
973
+ or line["speaker_id"] not in [0, 1]
974
+ ):
975
+ return (
976
+ jsonify({"error": "Invalid script content. Speaker ID must be 0 or 1"}),
977
+ 400,
978
+ )
979
+
980
+ # Get two conversational models (currently only CSM and PlayDialog)
981
+ available_models = Model.query.filter_by(
982
+ model_type=ModelType.CONVERSATIONAL, is_active=True
983
+ ).all()
984
+
985
+ if len(available_models) < 2:
986
+ return jsonify({"error": "Not enough conversational models available"}), 500
987
+
988
+ selected_models = get_weighted_random_models(available_models, 2, ModelType.CONVERSATIONAL)
989
+
990
+ try:
991
+ # Generate audio for both models concurrently
992
+ audio_files = []
993
+ model_ids = []
994
+
995
+ # Function to process a single model
996
+ def process_model(model):
997
+ # Call conversational TTS service
998
+ audio_content = predict_tts(script, model.id)
999
+
1000
+ # Save to temp file with unique name
1001
+ file_uuid = str(uuid.uuid4())
1002
+ dest_path = os.path.join(TEMP_AUDIO_DIR, f"{file_uuid}.wav")
1003
+
1004
+ with open(dest_path, "wb") as f:
1005
+ f.write(audio_content)
1006
+
1007
+ return {"model_id": model.id, "audio_path": dest_path}
1008
+
1009
+ # Use ThreadPoolExecutor to process models concurrently
1010
+ with ThreadPoolExecutor(max_workers=2) as executor:
1011
+ results = list(executor.map(process_model, selected_models))
1012
+
1013
+ # Extract results
1014
+ for result in results:
1015
+ model_ids.append(result["model_id"])
1016
+ audio_files.append(result["audio_path"])
1017
+
1018
+ # Create session
1019
+ session_id = str(uuid.uuid4())
1020
+ script_text = " ".join([line["text"] for line in script])
1021
+ app.conversational_sessions[session_id] = {
1022
+ "model_a": model_ids[0],
1023
+ "model_b": model_ids[1],
1024
+ "audio_a": audio_files[0],
1025
+ "audio_b": audio_files[1],
1026
+ "text": script_text[:1000], # Limit text length
1027
+ "created_at": datetime.utcnow(),
1028
+ "expires_at": datetime.utcnow() + timedelta(minutes=30),
1029
+ "voted": False,
1030
+ "script": script,
1031
+ "cache_hit": False, # Conversational is always generated on-demand
1032
+ }
1033
+
1034
+ # Return audio file paths and session
1035
+ return jsonify(
1036
+ {
1037
+ "session_id": session_id,
1038
+ "audio_a": f"/api/conversational/audio/{session_id}/a",
1039
+ "audio_b": f"/api/conversational/audio/{session_id}/b",
1040
+ "expires_in": 1800, # 30 minutes in seconds
1041
+ }
1042
+ )
1043
+
1044
+ except Exception as e:
1045
+ app.logger.error(f"Conversational generation error: {str(e)}")
1046
+ return jsonify({"error": f"Failed to generate podcast: {str(e)}"}), 500
1047
+
1048
+
1049
+ @app.route("/api/conversational/audio/<session_id>/<model_key>")
1050
+ def get_podcast_audio(session_id, model_key):
1051
+ # If verification not setup, handle it first
1052
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
1053
+ return jsonify({"error": "Turnstile verification required"}), 403
1054
+
1055
+ if session_id not in app.conversational_sessions:
1056
+ return jsonify({"error": "Invalid or expired session"}), 404
1057
+
1058
+ session_data = app.conversational_sessions[session_id]
1059
+
1060
+ # Check if session expired
1061
+ if datetime.utcnow() > session_data["expires_at"]:
1062
+ cleanup_conversational_session(session_id)
1063
+ return jsonify({"error": "Session expired"}), 410
1064
+
1065
+ if model_key == "a":
1066
+ audio_path = session_data["audio_a"]
1067
+ elif model_key == "b":
1068
+ audio_path = session_data["audio_b"]
1069
+ else:
1070
+ return jsonify({"error": "Invalid model key"}), 400
1071
+
1072
+ # Check if file exists
1073
+ if not os.path.exists(audio_path):
1074
+ return jsonify({"error": "Audio file not found"}), 404
1075
+
1076
+ return send_file(audio_path, mimetype="audio/wav")
1077
+
1078
+
1079
+ @app.route("/api/conversational/vote", methods=["POST"])
1080
+ @limiter.limit("30 per minute")
1081
+ def submit_podcast_vote():
1082
+ # If verification not setup, handle it first
1083
+ if app.config["TURNSTILE_ENABLED"] and not session.get("turnstile_verified"):
1084
+ return jsonify({"error": "Turnstile verification required"}), 403
1085
+
1086
+ # Require user to be logged in to vote
1087
+ if not current_user.is_authenticated:
1088
+ return jsonify({"error": "You must be logged in to vote"}), 401
1089
+
1090
+ # Security checks for vote manipulation prevention
1091
+ client_ip = get_client_ip()
1092
+ vote_allowed, security_reason, security_score = is_vote_allowed(current_user.id, client_ip)
1093
+
1094
+ if not vote_allowed:
1095
+ app.logger.warning(f"Conversational vote blocked for user {current_user.username} (ID: {current_user.id}): {security_reason} (Score: {security_score})")
1096
+ return jsonify({"error": f"Vote not allowed: {security_reason}"}), 403
1097
+
1098
+ data = request.json
1099
+ session_id = data.get("session_id")
1100
+ chosen_model_key = data.get("chosen_model") # "a" or "b"
1101
+
1102
+ if not session_id or session_id not in app.conversational_sessions:
1103
+ return jsonify({"error": "Invalid or expired session"}), 404
1104
+
1105
+ if not chosen_model_key or chosen_model_key not in ["a", "b"]:
1106
+ return jsonify({"error": "Invalid chosen model"}), 400
1107
+
1108
+ session_data = app.conversational_sessions[session_id]
1109
+
1110
+ # Check if session expired
1111
+ if datetime.utcnow() > session_data["expires_at"]:
1112
+ cleanup_conversational_session(session_id)
1113
+ return jsonify({"error": "Session expired"}), 410
1114
+
1115
+ # Check if already voted
1116
+ if session_data["voted"]:
1117
+ return jsonify({"error": "Vote already submitted for this session"}), 400
1118
+
1119
+ # Get model IDs and audio paths
1120
+ chosen_id = (
1121
+ session_data["model_a"] if chosen_model_key == "a" else session_data["model_b"]
1122
+ )
1123
+ rejected_id = (
1124
+ session_data["model_b"] if chosen_model_key == "a" else session_data["model_a"]
1125
+ )
1126
+ chosen_audio_path = (
1127
+ session_data["audio_a"] if chosen_model_key == "a" else session_data["audio_b"]
1128
+ )
1129
+ rejected_audio_path = (
1130
+ session_data["audio_b"] if chosen_model_key == "a" else session_data["audio_a"]
1131
+ )
1132
+
1133
+ # Calculate session duration and gather analytics data
1134
+ vote_time = datetime.utcnow()
1135
+ session_duration = (vote_time - session_data["created_at"]).total_seconds()
1136
+ client_ip = get_client_ip()
1137
+ user_agent = request.headers.get('User-Agent')
1138
+ cache_hit = session_data.get("cache_hit", False)
1139
+
1140
+ # Record vote in database with analytics data
1141
+ vote, error = record_vote(
1142
+ current_user.id,
1143
+ session_data["text"],
1144
+ chosen_id,
1145
+ rejected_id,
1146
+ ModelType.CONVERSATIONAL,
1147
+ session_duration=session_duration,
1148
+ ip_address=client_ip,
1149
+ user_agent=user_agent,
1150
+ generation_date=session_data["created_at"],
1151
+ cache_hit=cache_hit,
1152
+ all_dataset_sentences=all_harvard_sentences # Note: conversational uses scripts, not sentences
1153
+ )
1154
+
1155
+ if error:
1156
+ return jsonify({"error": error}), 500
1157
+
1158
+ # Sentence consumption is now handled within record_vote function
1159
+
1160
+ # --- Save preference data ---\
1161
+ try:
1162
+ vote_uuid = str(uuid.uuid4())
1163
+ vote_dir = os.path.join("./votes", vote_uuid)
1164
+ os.makedirs(vote_dir, exist_ok=True)
1165
+
1166
+ # Copy audio files
1167
+ shutil.copy(chosen_audio_path, os.path.join(vote_dir, "chosen.wav"))
1168
+ shutil.copy(rejected_audio_path, os.path.join(vote_dir, "rejected.wav"))
1169
+
1170
+ # Create metadata
1171
+ chosen_model_obj = Model.query.get(chosen_id)
1172
+ rejected_model_obj = Model.query.get(rejected_id)
1173
+ metadata = {
1174
+ "script": session_data["script"], # Save the full script
1175
+ "chosen_model": chosen_model_obj.name if chosen_model_obj else "Unknown",
1176
+ "chosen_model_id": chosen_model_obj.id if chosen_model_obj else "Unknown",
1177
+ "rejected_model": rejected_model_obj.name if rejected_model_obj else "Unknown",
1178
+ "rejected_model_id": rejected_model_obj.id if rejected_model_obj else "Unknown",
1179
+ "session_id": session_id,
1180
+ "timestamp": datetime.utcnow().isoformat(),
1181
+ "username": current_user.username,
1182
+ "model_type": "CONVERSATIONAL"
1183
+ }
1184
+ with open(os.path.join(vote_dir, "metadata.json"), "w") as f:
1185
+ json.dump(metadata, f, indent=2)
1186
+
1187
+ except Exception as e:
1188
+ app.logger.error(f"Error saving preference data for conversational vote {session_id}: {str(e)}")
1189
+ # Continue even if saving preference data fails, vote is already recorded
1190
+
1191
+ # Mark session as voted
1192
+ session_data["voted"] = True
1193
+
1194
+ # Check for coordinated voting campaigns (async to not slow down response)
1195
+ try:
1196
+ from threading import Thread
1197
+ campaign_check_thread = Thread(target=check_for_coordinated_campaigns)
1198
+ campaign_check_thread.daemon = True
1199
+ campaign_check_thread.start()
1200
+ except Exception as e:
1201
+ app.logger.error(f"Error starting coordinated campaign check thread: {str(e)}")
1202
+
1203
+ # Return updated models (use previously fetched objects)
1204
+ return jsonify(
1205
+ {
1206
+ "success": True,
1207
+ "chosen_model": {"id": chosen_id, "name": chosen_model_obj.name if chosen_model_obj else "Unknown"},
1208
+ "rejected_model": {
1209
+ "id": rejected_id,
1210
+ "name": rejected_model_obj.name if rejected_model_obj else "Unknown",
1211
+ },
1212
+ "names": {
1213
+ "a": Model.query.get(session_data["model_a"]).name,
1214
+ "b": Model.query.get(session_data["model_b"]).name,
1215
+ },
1216
+ }
1217
+ )
1218
+
1219
+
1220
+ def cleanup_conversational_session(session_id):
1221
+ """Remove conversational session and its audio files"""
1222
+ if session_id in app.conversational_sessions:
1223
+ session = app.conversational_sessions[session_id]
1224
+
1225
+ # Remove audio files
1226
+ for audio_file in [session["audio_a"], session["audio_b"]]:
1227
+ if os.path.exists(audio_file):
1228
+ try:
1229
+ os.remove(audio_file)
1230
+ except Exception as e:
1231
+ app.logger.error(
1232
+ f"Error removing conversational audio file: {str(e)}"
1233
+ )
1234
+
1235
+ # Remove session
1236
+ del app.conversational_sessions[session_id]
1237
+
1238
+
1239
+ # Schedule periodic cleanup
1240
+ def setup_cleanup():
1241
+ def cleanup_expired_sessions():
1242
+ with app.app_context(): # Ensure app context for logging
1243
+ current_time = datetime.utcnow()
1244
+ # Cleanup TTS sessions
1245
+ expired_tts_sessions = [
1246
+ sid
1247
+ for sid, session_data in app.tts_sessions.items()
1248
+ if current_time > session_data["expires_at"]
1249
+ ]
1250
+ for sid in expired_tts_sessions:
1251
+ cleanup_session(sid)
1252
+
1253
+ # Cleanup conversational sessions
1254
+ expired_conv_sessions = [
1255
+ sid
1256
+ for sid, session_data in app.conversational_sessions.items()
1257
+ if current_time > session_data["expires_at"]
1258
+ ]
1259
+ for sid in expired_conv_sessions:
1260
+ cleanup_conversational_session(sid)
1261
+ app.logger.info(f"Cleaned up {len(expired_tts_sessions)} TTS and {len(expired_conv_sessions)} conversational sessions.")
1262
+
1263
+ # Also cleanup potentially expired cache entries (e.g., > 1 hour old)
1264
+ # This prevents stale cache entries if generation is slow or failing
1265
+ # cleanup_stale_cache_entries()
1266
+
1267
+ # Run cleanup every 15 minutes
1268
+ scheduler = BackgroundScheduler(daemon=True) # Run scheduler as daemon thread
1269
+ scheduler.add_job(cleanup_expired_sessions, "interval", minutes=15)
1270
+ scheduler.start()
1271
+ print("Cleanup scheduler started") # Use print for startup messages
1272
+
1273
+
1274
+ # Schedule periodic tasks (database sync and preference upload)
1275
+ def setup_periodic_tasks():
1276
+ """Setup periodic database synchronization and preference data upload for Spaces"""
1277
+ if not IS_SPACES:
1278
+ return
1279
+
1280
+ db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "instance/") # Get relative path
1281
+ preferences_repo_id = "TTS-AGI/arena-v2-preferences"
1282
+ database_repo_id = "TTS-AGI/database-arena-v2"
1283
+ votes_dir = "./votes"
1284
+
1285
+ def sync_database():
1286
+ """Uploads the database to HF dataset"""
1287
+ with app.app_context(): # Ensure app context for logging
1288
+ try:
1289
+ if not os.path.exists(db_path):
1290
+ app.logger.warning(f"Database file not found at {db_path}, skipping sync.")
1291
+ return
1292
+
1293
+ api = HfApi(token=os.getenv("HF_TOKEN"))
1294
+ api.upload_file(
1295
+ path_or_fileobj=db_path,
1296
+ path_in_repo="tts_arena.db",
1297
+ repo_id=database_repo_id,
1298
+ repo_type="dataset",
1299
+ )
1300
+ app.logger.info(f"Database uploaded to {database_repo_id} at {datetime.utcnow()}")
1301
+ except Exception as e:
1302
+ app.logger.error(f"Error uploading database to {database_repo_id}: {str(e)}")
1303
+
1304
+ def sync_preferences_data():
1305
+ """Zips and uploads preference data folders in batches to HF dataset"""
1306
+ with app.app_context(): # Ensure app context for logging
1307
+ if not os.path.isdir(votes_dir):
1308
+ return # Don't log every 5 mins if dir doesn't exist yet
1309
+
1310
+ temp_batch_dir = None # Initialize to manage cleanup
1311
+ temp_individual_zip_dir = None # Initialize for individual zips
1312
+ local_batch_zip_path = None # Initialize for batch zip path
1313
+
1314
+ try:
1315
+ api = HfApi(token=os.getenv("HF_TOKEN"))
1316
+ vote_uuids = [d for d in os.listdir(votes_dir) if os.path.isdir(os.path.join(votes_dir, d))]
1317
+
1318
+ if not vote_uuids:
1319
+ return # No data to process
1320
+
1321
+ app.logger.info(f"Found {len(vote_uuids)} vote directories to process.")
1322
+
1323
+ # Create temporary directories
1324
+ temp_batch_dir = tempfile.mkdtemp(prefix="hf_batch_")
1325
+ temp_individual_zip_dir = tempfile.mkdtemp(prefix="hf_indiv_zips_")
1326
+ app.logger.debug(f"Created temp directories: {temp_batch_dir}, {temp_individual_zip_dir}")
1327
+
1328
+ processed_vote_dirs = []
1329
+ individual_zips_in_batch = []
1330
+
1331
+ # 1. Create individual zips and move them to the batch directory
1332
+ for vote_uuid in vote_uuids:
1333
+ dir_path = os.path.join(votes_dir, vote_uuid)
1334
+ individual_zip_base_path = os.path.join(temp_individual_zip_dir, vote_uuid)
1335
+ individual_zip_path = f"{individual_zip_base_path}.zip"
1336
+
1337
+ try:
1338
+ shutil.make_archive(individual_zip_base_path, 'zip', dir_path)
1339
+ app.logger.debug(f"Created individual zip: {individual_zip_path}")
1340
+
1341
+ # Move the created zip into the batch directory
1342
+ final_individual_zip_path = os.path.join(temp_batch_dir, f"{vote_uuid}.zip")
1343
+ shutil.move(individual_zip_path, final_individual_zip_path)
1344
+ app.logger.debug(f"Moved individual zip to batch dir: {final_individual_zip_path}")
1345
+
1346
+ processed_vote_dirs.append(dir_path) # Mark original dir for later cleanup
1347
+ individual_zips_in_batch.append(final_individual_zip_path)
1348
+
1349
+ except Exception as zip_err:
1350
+ app.logger.error(f"Error creating or moving zip for {vote_uuid}: {str(zip_err)}")
1351
+ # Clean up partial zip if it exists
1352
+ if os.path.exists(individual_zip_path):
1353
+ try:
1354
+ os.remove(individual_zip_path)
1355
+ except OSError:
1356
+ pass
1357
+ # Continue processing other votes
1358
+
1359
+ # Clean up the temporary dir used for creating individual zips
1360
+ shutil.rmtree(temp_individual_zip_dir)
1361
+ temp_individual_zip_dir = None # Mark as cleaned
1362
+ app.logger.debug("Cleaned up temporary individual zip directory.")
1363
+
1364
+ if not individual_zips_in_batch:
1365
+ app.logger.warning("No individual zips were successfully created for batching.")
1366
+ # Clean up batch dir if it's empty or only contains failed attempts
1367
+ if temp_batch_dir and os.path.exists(temp_batch_dir):
1368
+ shutil.rmtree(temp_batch_dir)
1369
+ temp_batch_dir = None
1370
+ return
1371
+
1372
+ # 2. Create the batch zip file
1373
+ batch_timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
1374
+ batch_uuid_short = str(uuid.uuid4())[:8]
1375
+ batch_zip_filename = f"{batch_timestamp}_batch_{batch_uuid_short}.zip"
1376
+ # Create batch zip in a standard temp location first
1377
+ local_batch_zip_base = os.path.join(tempfile.gettempdir(), batch_zip_filename.replace('.zip', ''))
1378
+ local_batch_zip_path = f"{local_batch_zip_base}.zip"
1379
+
1380
+ app.logger.info(f"Creating batch zip: {local_batch_zip_path} with {len(individual_zips_in_batch)} individual zips.")
1381
+ shutil.make_archive(local_batch_zip_base, 'zip', temp_batch_dir)
1382
+ app.logger.info(f"Batch zip created successfully: {local_batch_zip_path}")
1383
+
1384
+ # 3. Upload the batch zip file
1385
+ hf_repo_path = f"votes/{year}/{month}/{batch_zip_filename}"
1386
+ app.logger.info(f"Uploading batch zip to HF Hub: {preferences_repo_id}/{hf_repo_path}")
1387
+
1388
+ api.upload_file(
1389
+ path_or_fileobj=local_batch_zip_path,
1390
+ path_in_repo=hf_repo_path,
1391
+ repo_id=preferences_repo_id,
1392
+ repo_type="dataset",
1393
+ commit_message=f"Add batch preference data {batch_zip_filename} ({len(individual_zips_in_batch)} votes)"
1394
+ )
1395
+ app.logger.info(f"Successfully uploaded batch {batch_zip_filename} to {preferences_repo_id}")
1396
+
1397
+ # 4. Cleanup after successful upload
1398
+ app.logger.info("Cleaning up local files after successful upload.")
1399
+ # Remove original vote directories that were successfully zipped and uploaded
1400
+ for dir_path in processed_vote_dirs:
1401
+ try:
1402
+ shutil.rmtree(dir_path)
1403
+ app.logger.debug(f"Removed original vote directory: {dir_path}")
1404
+ except OSError as e:
1405
+ app.logger.error(f"Error removing processed vote directory {dir_path}: {str(e)}")
1406
+
1407
+ # Remove the temporary batch directory (containing the individual zips)
1408
+ shutil.rmtree(temp_batch_dir)
1409
+ temp_batch_dir = None
1410
+ app.logger.debug("Removed temporary batch directory.")
1411
+
1412
+ # Remove the local batch zip file
1413
+ os.remove(local_batch_zip_path)
1414
+ local_batch_zip_path = None
1415
+ app.logger.debug("Removed local batch zip file.")
1416
+
1417
+ app.logger.info(f"Finished preference data sync. Uploaded batch {batch_zip_filename}.")
1418
+
1419
+ except Exception as e:
1420
+ app.logger.error(f"Error during preference data batch sync: {str(e)}", exc_info=True)
1421
+ # If upload failed, the local batch zip might exist, clean it up.
1422
+ if local_batch_zip_path and os.path.exists(local_batch_zip_path):
1423
+ try:
1424
+ os.remove(local_batch_zip_path)
1425
+ app.logger.debug("Cleaned up local batch zip after failed upload.")
1426
+ except OSError as clean_err:
1427
+ app.logger.error(f"Error cleaning up batch zip after failed upload: {clean_err}")
1428
+ # Do NOT remove temp_batch_dir if it exists; its contents will be retried next time.
1429
+ # Do NOT remove original vote directories if upload failed.
1430
+
1431
+ finally:
1432
+ # Final cleanup for temporary directories in case of unexpected exits
1433
+ if temp_individual_zip_dir and os.path.exists(temp_individual_zip_dir):
1434
+ try:
1435
+ shutil.rmtree(temp_individual_zip_dir)
1436
+ except Exception as final_clean_err:
1437
+ app.logger.error(f"Error in final cleanup (indiv zips): {final_clean_err}")
1438
+ # Only clean up batch dir in finally block if it *wasn't* kept intentionally after upload failure
1439
+ if temp_batch_dir and os.path.exists(temp_batch_dir):
1440
+ # Check if an upload attempt happened and failed
1441
+ upload_failed = 'e' in locals() and isinstance(e, Exception) # Crude check if exception occurred
1442
+ if not upload_failed: # If no upload error or upload succeeded, clean up
1443
+ try:
1444
+ shutil.rmtree(temp_batch_dir)
1445
+ except Exception as final_clean_err:
1446
+ app.logger.error(f"Error in final cleanup (batch dir): {final_clean_err}")
1447
+ else:
1448
+ app.logger.warning("Keeping temporary batch directory due to upload failure for next attempt.")
1449
+
1450
+
1451
+ # Schedule periodic tasks
1452
+ scheduler = BackgroundScheduler()
1453
+ # Sync database less frequently if needed, e.g., every 15 minutes
1454
+ scheduler.add_job(sync_database, "interval", minutes=15, id="sync_db_job")
1455
+ # Sync preferences more frequently
1456
+ scheduler.add_job(sync_preferences_data, "interval", minutes=5, id="sync_pref_job")
1457
+ scheduler.start()
1458
+ print("Periodic tasks scheduler started (DB sync and Preferences upload)") # Use print for startup
1459
+
1460
+
1461
+ @app.cli.command("init-db")
1462
+ def init_db():
1463
+ """Initialize the database."""
1464
+ with app.app_context():
1465
+ db.create_all()
1466
+ print("Database initialized!")
1467
+
1468
+
1469
+ @app.route("/api/toggle-leaderboard-visibility", methods=["POST"])
1470
+ def toggle_leaderboard_visibility():
1471
+ """Toggle whether the current user appears in the top voters leaderboard"""
1472
+ if not current_user.is_authenticated:
1473
+ return jsonify({"error": "You must be logged in to change this setting"}), 401
1474
+
1475
+ new_status = toggle_user_leaderboard_visibility(current_user.id)
1476
+ if new_status is None:
1477
+ return jsonify({"error": "User not found"}), 404
1478
+
1479
+ return jsonify({
1480
+ "success": True,
1481
+ "visible": new_status,
1482
+ "message": "You are now visible in the voters leaderboard" if new_status else "You are now hidden from the voters leaderboard"
1483
+ })
1484
+
1485
+
1486
+ @app.route("/api/tts/cached-sentences")
1487
+ def get_cached_sentences():
1488
+ """Returns a list of unconsumed sentences available for random selection."""
1489
+ # Get unconsumed sentences from the full pool (not just cached ones)
1490
+ unconsumed_sentences = get_unconsumed_sentences(all_harvard_sentences)
1491
+
1492
+ # Limit the response size to avoid overwhelming the frontend
1493
+ max_sentences = 1000
1494
+ if len(unconsumed_sentences) > max_sentences:
1495
+ import random
1496
+ unconsumed_sentences = random.sample(unconsumed_sentences, max_sentences)
1497
+
1498
+ return jsonify(unconsumed_sentences)
1499
+
1500
+
1501
+ @app.route("/api/tts/sentence-stats")
1502
+ def get_sentence_stats():
1503
+ """Returns statistics about sentence consumption."""
1504
+ total_sentences = len(all_harvard_sentences)
1505
+ consumed_count = get_consumed_sentences_count()
1506
+ remaining_count = total_sentences - consumed_count
1507
+
1508
+ return jsonify({
1509
+ "total_sentences": total_sentences,
1510
+ "consumed_sentences": consumed_count,
1511
+ "remaining_sentences": remaining_count,
1512
+ "consumption_percentage": round((consumed_count / total_sentences) * 100, 2) if total_sentences > 0 else 0
1513
+ })
1514
+
1515
+
1516
+ @app.route("/api/tts/random-sentence")
1517
+ def get_random_sentence():
1518
+ """Returns a random unconsumed sentence."""
1519
+ random_sentence = get_random_unconsumed_sentence(all_harvard_sentences)
1520
+ if random_sentence:
1521
+ return jsonify({"sentence": random_sentence})
1522
+ else:
1523
+ total_sentences = len(all_harvard_sentences)
1524
+ consumed_count = get_consumed_sentences_count()
1525
+ return jsonify({
1526
+ "error": "No unconsumed sentences available",
1527
+ "details": f"All {total_sentences} sentences have been consumed ({consumed_count} total consumed)"
1528
+ }), 404
1529
+
1530
+
1531
+ def get_weighted_random_models(
1532
+ applicable_models: list[Model], num_to_select: int, model_type: ModelType
1533
+ ) -> list[Model]:
1534
+ """
1535
+ Selects a specified number of models randomly from a list of applicable_models,
1536
+ weighting models with fewer votes higher. A smoothing factor is used to ensure
1537
+ the preference is slight and to prevent models with zero votes from being
1538
+ overwhelmingly favored. Models are selected without replacement.
1539
+
1540
+ Assumes len(applicable_models) >= num_to_select, which should be checked by the caller.
1541
+ """
1542
+ model_votes_counts = {}
1543
+ for model in applicable_models:
1544
+ votes = (
1545
+ Vote.query.filter(Vote.model_type == model_type)
1546
+ .filter(or_(Vote.model_chosen == model.id, Vote.model_rejected == model.id))
1547
+ .count()
1548
+ )
1549
+ model_votes_counts[model.id] = votes
1550
+
1551
+ weights = [
1552
+ 1.0 / (model_votes_counts[model.id] + SMOOTHING_FACTOR_MODEL_SELECTION)
1553
+ for model in applicable_models
1554
+ ]
1555
+
1556
+ selected_models_list = []
1557
+ # Create copies to modify during selection process
1558
+ current_candidates = list(applicable_models)
1559
+ current_weights = list(weights)
1560
+
1561
+ # Assumes num_to_select is positive and less than or equal to len(current_candidates)
1562
+ # Callers should ensure this (e.g., len(available_models) >= 2).
1563
+ for _ in range(num_to_select):
1564
+ if not current_candidates: # Safety break
1565
+ app.logger.warning("Not enough candidates left for weighted selection.")
1566
+ break
1567
+
1568
+ chosen_model = random.choices(current_candidates, weights=current_weights, k=1)[0]
1569
+ selected_models_list.append(chosen_model)
1570
+
1571
+ try:
1572
+ idx_to_remove = current_candidates.index(chosen_model)
1573
+ current_candidates.pop(idx_to_remove)
1574
+ current_weights.pop(idx_to_remove)
1575
+ except ValueError:
1576
+ # This should ideally not happen if chosen_model came from current_candidates.
1577
+ app.logger.error(f"Error removing model {chosen_model.id} from weighted selection candidates.")
1578
+ break # Avoid potential issues
1579
+
1580
+ return selected_models_list
1581
+
1582
+
1583
+ def check_for_coordinated_campaigns():
1584
+ """Check all active models for potential coordinated voting campaigns"""
1585
+ try:
1586
+ from security import detect_coordinated_voting
1587
+ from models import Model, ModelType
1588
+
1589
+ # Check TTS models
1590
+ tts_models = Model.query.filter_by(model_type=ModelType.TTS, is_active=True).all()
1591
+ for model in tts_models:
1592
+ try:
1593
+ detect_coordinated_voting(model.id)
1594
+ except Exception as e:
1595
+ app.logger.error(f"Error checking coordinated voting for TTS model {model.id}: {str(e)}")
1596
+
1597
+ # Check conversational models
1598
+ conv_models = Model.query.filter_by(model_type=ModelType.CONVERSATIONAL, is_active=True).all()
1599
+ for model in conv_models:
1600
+ try:
1601
+ detect_coordinated_voting(model.id)
1602
+ except Exception as e:
1603
+ app.logger.error(f"Error checking coordinated voting for conversational model {model.id}: {str(e)}")
1604
+
1605
+ except Exception as e:
1606
+ app.logger.error(f"Error in coordinated campaign check: {str(e)}")
1607
+
1608
+
1609
+ if __name__ == "__main__":
1610
+ with app.app_context():
1611
+ # Ensure ./instance and ./votes directories exist
1612
+ os.makedirs("instance", exist_ok=True)
1613
+ os.makedirs("./votes", exist_ok=True) # Create votes directory if it doesn't exist
1614
+ os.makedirs(CACHE_AUDIO_DIR, exist_ok=True) # Ensure cache audio dir exists
1615
+
1616
+ # Clean up old cache audio files on startup
1617
+ try:
1618
+ app.logger.info(f"Clearing old cache audio files from {CACHE_AUDIO_DIR}")
1619
+ for filename in os.listdir(CACHE_AUDIO_DIR):
1620
+ file_path = os.path.join(CACHE_AUDIO_DIR, filename)
1621
+ try:
1622
+ if os.path.isfile(file_path) or os.path.islink(file_path):
1623
+ os.unlink(file_path)
1624
+ elif os.path.isdir(file_path):
1625
+ shutil.rmtree(file_path)
1626
+ except Exception as e:
1627
+ app.logger.error(f'Failed to delete {file_path}. Reason: {e}')
1628
+ except Exception as e:
1629
+ app.logger.error(f"Error clearing cache directory {CACHE_AUDIO_DIR}: {e}")
1630
+
1631
+
1632
+ # Download database if it doesn't exist (only on initial space start)
1633
+ if IS_SPACES and not os.path.exists(app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")):
1634
+ try:
1635
+ print("Database not found, downloading from HF dataset...")
1636
+ hf_hub_download(
1637
+ repo_id="TTS-AGI/database-arena-v2",
1638
+ filename="tts_arena.db",
1639
+ repo_type="dataset",
1640
+ local_dir="instance", # download to instance/
1641
+ token=os.getenv("HF_TOKEN"),
1642
+ )
1643
+ print("Database downloaded successfully ✅")
1644
+ except Exception as e:
1645
+ print(f"Error downloading database from HF dataset: {str(e)} ⚠️")
1646
+
1647
+
1648
+ db.create_all() # Create tables if they don't exist
1649
+ insert_initial_models()
1650
+ # Setup background tasks
1651
+ initialize_tts_cache() # Start populating the cache
1652
+ setup_cleanup()
1653
+ setup_periodic_tasks() # Renamed function call
1654
+
1655
+ # Configure Flask to recognize HTTPS when behind a reverse proxy
1656
+ from werkzeug.middleware.proxy_fix import ProxyFix
1657
+
1658
+ # Apply ProxyFix middleware to handle reverse proxy headers
1659
+ # This ensures Flask generates correct URLs with https scheme
1660
+ # X-Forwarded-Proto header will be used to detect the original protocol
1661
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
1662
+
1663
+ # Force Flask to prefer HTTPS for generated URLs
1664
+ app.config["PREFERRED_URL_SCHEME"] = "https"
1665
+
1666
+ from waitress import serve
1667
+
1668
+ # Configuration for 2 vCPUs:
1669
+ # - threads: typically 4-8 threads per CPU core is a good balance
1670
+ # - connection_limit: maximum concurrent connections
1671
+ # - channel_timeout: prevent hanging connections
1672
+ threads = 12 # 6 threads per vCPU is a good balance for mixed IO/CPU workloads
1673
+
1674
+ if IS_SPACES:
1675
+ serve(
1676
+ app,
1677
+ host="0.0.0.0",
1678
+ port=int(os.environ.get("PORT", 7860)),
1679
+ threads=threads,
1680
+ connection_limit=100,
1681
+ channel_timeout=30,
1682
+ url_scheme='https'
1683
+ )
1684
+ else:
1685
+ print(f"Starting Waitress server with {threads} threads")
1686
+ serve(
1687
+ app,
1688
+ host="0.0.0.0",
1689
+ port=5000,
1690
+ threads=threads,
1691
+ connection_limit=100,
1692
+ channel_timeout=30,
1693
+ url_scheme='https' # Keep https for local dev if using proxy/tunnel
1694
+ )
apps/docs/.gitignore DELETED
@@ -1,26 +0,0 @@
1
- # deps
2
- /node_modules
3
-
4
- # generated content
5
- .source
6
-
7
- # test & build
8
- /coverage
9
- /.next/
10
- /out/
11
- /build
12
- *.tsbuildinfo
13
-
14
- # misc
15
- .DS_Store
16
- *.pem
17
- /.pnp
18
- .pnp.js
19
- npm-debug.log*
20
- yarn-debug.log*
21
- yarn-error.log*
22
-
23
- # others
24
- .env*.local
25
- .vercel
26
- next-env.d.ts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/app/[[...slug]]/page.tsx DELETED
@@ -1,67 +0,0 @@
1
- import { getPageImage, getPageMarkdownUrl, source } from "@/lib/source";
2
- import {
3
- DocsBody,
4
- DocsDescription,
5
- DocsPage,
6
- DocsTitle,
7
- MarkdownCopyButton,
8
- ViewOptionsPopover,
9
- } from "fumadocs-ui/layouts/docs/page";
10
- import { notFound } from "next/navigation";
11
- import { getMDXComponents } from "@/components/mdx";
12
- import type { Metadata } from "next";
13
- import { createRelativeLink } from "fumadocs-ui/mdx";
14
- import { gitConfig } from "@/lib/shared";
15
-
16
- export default async function Page(props: PageProps<"/[[...slug]]">) {
17
- const params = await props.params;
18
- const page = source.getPage(params.slug);
19
- if (!page) notFound();
20
-
21
- const MDX = page.data.body;
22
- const markdownUrl = getPageMarkdownUrl(page).url;
23
-
24
- return (
25
- <DocsPage toc={page.data.toc} full={page.data.full}>
26
- <DocsTitle>{page.data.title}</DocsTitle>
27
- <DocsDescription className="mb-0">
28
- {page.data.description}
29
- </DocsDescription>
30
- <div className="flex flex-row items-center gap-2 border-b pb-6">
31
- <MarkdownCopyButton markdownUrl={markdownUrl} />
32
- <ViewOptionsPopover
33
- markdownUrl={markdownUrl}
34
- githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
35
- />
36
- </div>
37
- <DocsBody>
38
- <MDX
39
- components={getMDXComponents({
40
- // this allows you to link to other pages with relative file paths
41
- a: createRelativeLink(source, page),
42
- })}
43
- />
44
- </DocsBody>
45
- </DocsPage>
46
- );
47
- }
48
-
49
- export async function generateStaticParams() {
50
- return source.generateParams();
51
- }
52
-
53
- export async function generateMetadata(
54
- props: PageProps<"/[[...slug]]">,
55
- ): Promise<Metadata> {
56
- const params = await props.params;
57
- const page = source.getPage(params.slug);
58
- if (!page) notFound();
59
-
60
- return {
61
- title: page.data.title,
62
- description: page.data.description,
63
- openGraph: {
64
- images: getPageImage(page).url,
65
- },
66
- };
67
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/app/api/search/route.ts DELETED
@@ -1,7 +0,0 @@
1
- import { source } from "@/lib/source";
2
- import { createFromSource } from "fumadocs-core/search/server";
3
-
4
- export const { GET } = createFromSource(source, {
5
- // https://docs.orama.com/docs/orama-js/supported-languages
6
- language: "english",
7
- });
 
 
 
 
 
 
 
 
apps/docs/app/global.css DELETED
@@ -1,12 +0,0 @@
1
- @import "tailwindcss";
2
- @import "fumadocs-ui/css/neutral.css";
3
- @import "fumadocs-ui/css/preset.css";
4
-
5
- html {
6
- scrollbar-gutter: stable;
7
- }
8
-
9
- html > body[data-scroll-locked] {
10
- margin-right: 0px !important;
11
- --removed-body-scroll-bar-size: 0px !important;
12
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/app/layout.tsx DELETED
@@ -1,37 +0,0 @@
1
- import type { Metadata } from "next";
2
- import { RootProvider } from "fumadocs-ui/provider/next";
3
- import { DocsLayout } from "fumadocs-ui/layouts/docs";
4
- import "./global.css";
5
- import { Inter } from "next/font/google";
6
- import { source } from "@/lib/source";
7
- import { baseOptions } from "@/lib/layout.shared";
8
-
9
- const inter = Inter({
10
- subsets: ["latin"],
11
- });
12
-
13
- export const metadata: Metadata = {
14
- metadataBase: new URL("https://docs.ttsarena.org"),
15
- title: {
16
- default: "TTS Arena Docs",
17
- template: "%s · TTS Arena Docs",
18
- },
19
- description:
20
- "Documentation for TTS Arena — the crowdsourced text-to-speech benchmark.",
21
- };
22
-
23
- export default function Layout({ children }: LayoutProps<"/">) {
24
- return (
25
- <html lang="en" className={inter.className} suppressHydrationWarning>
26
- <body className="flex min-h-screen flex-col">
27
- <RootProvider>
28
- {/* Docs are the whole site — the DocsLayout (sidebar + nav) wraps
29
- everything at the root; there is no separate homepage. */}
30
- <DocsLayout tree={source.getPageTree()} {...baseOptions()}>
31
- {children}
32
- </DocsLayout>
33
- </RootProvider>
34
- </body>
35
- </html>
36
- );
37
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/app/llms-full.txt/route.ts DELETED
@@ -1,10 +0,0 @@
1
- import { getLLMText, source } from "@/lib/source";
2
-
3
- export const revalidate = false;
4
-
5
- export async function GET() {
6
- const scan = source.getPages().map(getLLMText);
7
- const scanned = await Promise.all(scan);
8
-
9
- return new Response(scanned.join("\n\n"));
10
- }
 
 
 
 
 
 
 
 
 
 
 
apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts DELETED
@@ -1,26 +0,0 @@
1
- import { getLLMText, getPageMarkdownUrl, source } from "@/lib/source";
2
- import { notFound } from "next/navigation";
3
-
4
- export const revalidate = false;
5
-
6
- export async function GET(
7
- _req: Request,
8
- { params }: RouteContext<"/llms.mdx/docs/[[...slug]]">,
9
- ) {
10
- const { slug } = await params;
11
- const page = source.getPage(slug?.slice(0, -1));
12
- if (!page) notFound();
13
-
14
- return new Response(await getLLMText(page), {
15
- headers: {
16
- "Content-Type": "text/markdown",
17
- },
18
- });
19
- }
20
-
21
- export function generateStaticParams() {
22
- return source.getPages().map((page) => ({
23
- lang: page.locale,
24
- slug: getPageMarkdownUrl(page).segments,
25
- }));
26
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/app/llms.txt/route.ts DELETED
@@ -1,8 +0,0 @@
1
- import { source } from "@/lib/source";
2
- import { llms } from "fumadocs-core/source";
3
-
4
- export const revalidate = false;
5
-
6
- export function GET() {
7
- return new Response(llms(source).index());
8
- }
 
 
 
 
 
 
 
 
 
apps/docs/app/og/docs/[...slug]/route.tsx DELETED
@@ -1,35 +0,0 @@
1
- import { getPageImage, source } from "@/lib/source";
2
- import { notFound } from "next/navigation";
3
- import { ImageResponse } from "next/og";
4
- import { generate as DefaultImage } from "fumadocs-ui/og";
5
- import { appName } from "@/lib/shared";
6
-
7
- export const revalidate = false;
8
-
9
- export async function GET(
10
- _req: Request,
11
- { params }: RouteContext<"/og/docs/[...slug]">,
12
- ) {
13
- const { slug } = await params;
14
- const page = source.getPage(slug.slice(0, -1));
15
- if (!page) notFound();
16
-
17
- return new ImageResponse(
18
- <DefaultImage
19
- title={page.data.title}
20
- description={page.data.description}
21
- site={appName}
22
- />,
23
- {
24
- width: 1200,
25
- height: 630,
26
- },
27
- );
28
- }
29
-
30
- export function generateStaticParams() {
31
- return source.getPages().map((page) => ({
32
- lang: page.locale,
33
- slug: getPageImage(page).segments,
34
- }));
35
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/components/mdx.tsx DELETED
@@ -1,15 +0,0 @@
1
- import defaultMdxComponents from "fumadocs-ui/mdx";
2
- import type { MDXComponents } from "mdx/types";
3
-
4
- export function getMDXComponents(components?: MDXComponents) {
5
- return {
6
- ...defaultMdxComponents,
7
- ...components,
8
- } satisfies MDXComponents;
9
- }
10
-
11
- export const useMDXComponents = getMDXComponents;
12
-
13
- declare global {
14
- type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
15
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/content/docs/api.mdx DELETED
@@ -1,53 +0,0 @@
1
- ---
2
- title: Provider API
3
- description: The contract your TTS endpoint needs to join the arena.
4
- ---
5
-
6
- The arena talks to your model through a small HTTP contract. Our router calls
7
- your endpoint server-side, downloads the audio, and proxies it to the client -
8
- so the response never reaches the browser directly and your keys stay private.
9
-
10
- ## The request
11
-
12
- We send a line of text and (optionally) a voice id. Your endpoint synthesizes it
13
- and returns audio. A typical shape:
14
-
15
- ```http
16
- POST https://your-api.example.com/tts
17
- Authorization: Bearer <key>
18
- Content-Type: application/json
19
-
20
- {
21
- "text": "The quick brown fox jumps over the lazy dog.",
22
- "voice_id": "one-of-your-voice-ids"
23
- }
24
- ```
25
-
26
- The exact field names are flexible - we adapt a small provider adapter per
27
- model. What matters is that, given text, you return speech.
28
-
29
- ## The response
30
-
31
- Either is fine:
32
-
33
- - **Raw audio bytes** (`audio/mpeg`, `audio/wav`, …) returned directly, or
34
- - **JSON** containing base64 audio or a public URL we can download.
35
-
36
- Common formats (mp3, wav, ogg, flac, opus) all work; we normalize on our side.
37
-
38
- ## Voices
39
-
40
- The arena cycles a **fixed pool of voices** rather than cloning. Provide a list
41
- of voice ids and we rotate through them across battles, so a model is judged
42
- across its range rather than a single voice.
43
-
44
- ## Reliability
45
-
46
- - Aim to respond within ~15-30s for a sentence-length prompt.
47
- - Return a non-2xx (or an error payload) on failure - we record it, retry with
48
- another model, and surface failing models in our admin tooling.
49
-
50
- <Callout type="info">
51
- Latency and success rate are tracked per model. A model that fails frequently
52
- can be temporarily timed out so it doesn't disrupt battles.
53
- </Callout>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/content/docs/development.mdx DELETED
@@ -1,97 +0,0 @@
1
- ---
2
- title: Development
3
- description: Run TTS Arena locally and find your way around the codebase.
4
- ---
5
-
6
- TTS Arena is a [Bun](https://bun.sh) monorepo. The web app is Next.js; the
7
- router is a separate Hono service that talks to providers.
8
-
9
- ## Prerequisites
10
-
11
- - [Bun](https://bun.sh) 1.1.42
12
- - A Postgres database
13
- - A Hugging Face OAuth app for sign-in
14
- ([create one](https://huggingface.co/settings/applications/new))
15
-
16
- ## Setup
17
-
18
- ```bash
19
- git clone https://github.com/TTS-AGI/TTS-Arena
20
- cd TTS-Arena
21
- bun install
22
- ```
23
-
24
- Point `DATABASE_URL` at your Postgres instance, then create the schema and seed
25
- a starter set of models:
26
-
27
- ```bash
28
- cd apps/web
29
- bun run db:migrate
30
- bun run db:seed
31
- ```
32
-
33
- ## Configuration
34
-
35
- Set these in the environment (e.g. an `.env` the web app can read):
36
-
37
- | Variable | Required | Notes |
38
- | ------------------------ | -------- | ------------------------------------------------------------------------- |
39
- | `DATABASE_URL` | yes | Postgres connection string. Add `?sslmode=require` for a TLS-only server. |
40
- | `HF_OAUTH_CLIENT_ID` | yes | From your Hugging Face OAuth app. |
41
- | `HF_OAUTH_CLIENT_SECRET` | yes | Same app. |
42
- | `SESSION_SECRET` | yes | Any long random string. |
43
- | `ADMIN_USERS` | no | Comma-separated HF usernames with admin access. |
44
- | `ROUTER_URL` | no | Router base URL. Defaults to `http://localhost:8080`. |
45
- | `APP_URL` | no | Public app URL. Defaults to `http://localhost:3000`. |
46
- | `SECURITY_DISABLED` | no | Set to `1` to turn off the anti-fraud gate in local dev. |
47
-
48
- Provider API keys are read by the router from the environment too - add them as
49
- you wire up providers.
50
-
51
- ## Running
52
-
53
- ```bash
54
- bun run dev # web app on :3000
55
- bun run dev:router # router on :8080
56
- ```
57
-
58
- ## Useful scripts
59
-
60
- Run from the repo root:
61
-
62
- ```bash
63
- bun run build # build every workspace
64
- bun run lint # lint every workspace
65
- bun run typecheck # type-check every workspace
66
- bun test # run tests
67
- bun run format # prettier --write
68
- ```
69
-
70
- And from `apps/web` for database work:
71
-
72
- ```bash
73
- bun run db:generate # generate a migration from schema changes
74
- bun run db:migrate # apply migrations
75
- bun run db:seed # seed models from the provider packages
76
- bun run db:recompute # replay clean votes and rebuild ratings
77
- ```
78
-
79
- ## Layout
80
-
81
- ```
82
- apps/web Next.js app - arena, leaderboard, admin
83
- apps/router Hono service that calls TTS providers
84
- apps/docs this documentation site (Fumadocs)
85
- packages/shared shared types + the rating math
86
- packages/provider-sdk provider interface + registry
87
- packages/providers/* individual public providers
88
- ```
89
-
90
- The rating logic lives in `packages/shared` - `bradley-terry.ts` and
91
- `glicko.ts` - with tests alongside. See [Ranking](/ranking) for how it's used.
92
-
93
- ## Contributing
94
-
95
- Issues and pull requests are welcome on
96
- [GitHub](https://github.com/TTS-AGI/TTS-Arena). The project is licensed under
97
- [Apache 2.0](https://github.com/TTS-AGI/TTS-Arena/blob/main/LICENSE).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/content/docs/index.mdx DELETED
@@ -1,46 +0,0 @@
1
- ---
2
- title: Introduction
3
- description: A crowdsourced, blind benchmark for text-to-speech.
4
- ---
5
-
6
- **TTS Arena** ranks text-to-speech models by ear. You type a line, two anonymous
7
- models read it back, and you pick the one that sounds more human. Each vote
8
- feeds the leaderboard.
9
-
10
- The models stay hidden until you've voted, so the choice is about the audio, not
11
- the name attached to it.
12
-
13
- [**Open the arena and vote →**](https://huggingface.co/spaces/TTS-AGI/TTS-Arena-V2)
14
-
15
- ## Why
16
-
17
- There hasn't been a good way to measure how natural a synthetic voice sounds.
18
- Word error rate tells you whether speech is intelligible, not whether it sounds
19
- alive. Mean opinion scores rely on a small panel in a lab. TTS Arena uses
20
- large-scale human preference instead - anyone can listen, compare, and vote, and
21
- the resulting leaderboard is open.
22
-
23
- ## Start here
24
-
25
- <Cards>
26
- <Card title="Voting" href="/voting">
27
- How to vote and the rules that keep the board fair.
28
- </Card>
29
- <Card title="Ranking" href="/ranking">
30
- How votes become a leaderboard.
31
- </Card>
32
- <Card title="Submit a model" href="/submit-a-model">
33
- Add your model, publicly or under a codename.
34
- </Card>
35
- <Card title="Provider API" href="/api">
36
- The HTTP contract your TTS endpoint needs to meet.
37
- </Card>
38
- </Cards>
39
-
40
- ## Quick facts
41
-
42
- - Sign in with Hugging Face to vote; accounts must be at least 30 days old.
43
- - Prompts are English-only for now, capped at 1,000 characters.
44
- - Models are revealed only after you vote.
45
- - TTS Arena is open source under Apache 2.0 -
46
- [source on GitHub](https://github.com/TTS-AGI/TTS-Arena).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/content/docs/meta.json DELETED
@@ -1,11 +0,0 @@
1
- {
2
- "title": "Docs",
3
- "pages": [
4
- "index",
5
- "voting",
6
- "ranking",
7
- "submit-a-model",
8
- "api",
9
- "development"
10
- ]
11
- }
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/content/docs/ranking.mdx DELETED
@@ -1,56 +0,0 @@
1
- ---
2
- title: Ranking
3
- description: How votes become a leaderboard.
4
- ---
5
-
6
- Each counting vote is one head-to-head result: model A beat model B on a
7
- controlled prompt from the Random pool. The leaderboard is what falls out when
8
- you fit all of those results together.
9
-
10
- ## The rating
11
-
12
- We rank models with a [Bradley–Terry](https://en.wikipedia.org/wiki/Bradley%E2%80%93Terry_model)
13
- model - a standard way to turn pairwise wins and losses into a single strength
14
- score per competitor. It's fit over the entire vote history at once, so a
15
- model's rating reflects every matchup it has been in, not just its recent ones.
16
- The result doesn't depend on the order votes arrived in.
17
-
18
- Ratings are centered around 1500, so the numbers read like familiar Elo scores.
19
- The fit is cached and refreshed as votes come in.
20
-
21
- ## Rank by the lower bound
22
-
23
- A model's rating comes with a confidence interval - wide when there's little
24
- data, narrow once it has played a lot. We show the rating but **sort by the
25
- bottom of that interval**.
26
-
27
- The effect: a model can't shoot to the top off a handful of lucky wins. It has
28
- to be both good and well-tested to rank highly. Each row shows a `±` next to its
29
- rating so you can see how settled it is.
30
-
31
- ## New models
32
-
33
- - A model needs **100 votes** to appear on the board. Below that the rating
34
- swings too much to mean anything.
35
- - Under **300 votes** it's marked **Preliminary** - ranked normally, but still
36
- moving.
37
- - Brand-new models with only a few votes are hidden by default. Tick **Show new
38
- models with few votes** on the leaderboard to see them, with wide error bars.
39
-
40
- To keep tiny samples from producing absurd numbers (a model that has only ever
41
- won would otherwise rate infinitely high), the fit is lightly regularized toward
42
- the average. The nudge fades as real votes accumulate and is gone within a few
43
- hundred.
44
-
45
- ## Why blind and pairwise
46
-
47
- Knowing a model's name changes how people hear it, so identities stay hidden
48
- until after the vote. And "which of these two is better?" is a far easier and
49
- more reliable judgment than scoring a single clip out of context - it's how
50
- listening tests have always been run.
51
-
52
- <Callout type="info">
53
- Only clean votes on first-use Random prompts count. Typed custom prompts,
54
- votes flagged by the anti-fraud system, and votes from quarantined accounts
55
- are left out of the fit. See [Voting](/voting).
56
- </Callout>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/content/docs/submit-a-model.mdx DELETED
@@ -1,38 +0,0 @@
1
- ---
2
- title: Submit a model
3
- description: Add your TTS model for evaluation - public or anonymous.
4
- ---
5
-
6
- Want your model on the board? Open an issue on [GitHub](https://github.com/TTS-AGI/TTS-Arena), join our [Discord](https://discord.gg/HB8fMR6GTr), or [email us](mailto:me@mrfake.name) and include an
7
- API endpoint and key (see the [Provider API](/api)).
8
-
9
- ## What we need
10
-
11
- - An HTTP endpoint that synthesizes a line of text and returns audio.
12
- - A pool of voices to rotate through (we don't do zero-shot cloning in the
13
- arena - we cycle a fixed set).
14
- - A display name for the leaderboard.
15
-
16
- ## Anonymous pre-release
17
-
18
- You can run under a codename before your model is public - a way to get honest,
19
- blind feedback ahead of launch. Two rules keep this fair:
20
-
21
- - Anonymity is **time/exposure-bounded, not performance-bounded.** A codenamed
22
- entry is revealed after a set period or vote count regardless of how it does -
23
- you can withdraw it before then, but you can't sit on the board indefinitely
24
- or reveal it _only because it won_.
25
- - **Permanent anonymity is for genuinely unreleased models.** A model that's
26
- already publicly available runs under its real name (a codename for a fixed
27
- window is fine).
28
-
29
- <Callout type="warn">
30
- To keep comparisons fair, we don't evaluate multiple versions of the same
31
- model simultaneously.
32
- </Callout>
33
-
34
- ## Stealth models
35
-
36
- Anonymous entries appear on the leaderboard under their codename with a neutral
37
- mark. Clicking one explains it's a stealth model in evaluation - its identity
38
- stays hidden until it's revealed.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/content/docs/voting.mdx DELETED
@@ -1,45 +0,0 @@
1
- ---
2
- title: Voting
3
- description: What makes a vote count, and the rules that keep the board fair.
4
- ---
5
-
6
- ## Casting a vote
7
-
8
- 1. Type a line, or hit **Random** for one from the prompt pool.
9
- 2. Two anonymous models - **A** and **B** - synthesize it.
10
- 3. Listen to both, then pick the one that sounds more human. One choice, no
11
- skips.
12
- 4. The identities are revealed. If the line came unchanged from **Random**, the
13
- public ratings update.
14
-
15
- You need to listen to enough of each clip before voting unlocks - this keeps
16
- votes grounded in the audio rather than reflexive clicks.
17
-
18
- ## Requirements
19
-
20
- - **Sign in with Hugging Face.** Voting is tied to your account so each vote
21
- counts once. Accounts must be at least **30 days old**.
22
- - **English only**, for now - it's the language all models support. Multilingual
23
- is on the roadmap.
24
- - Prompts are capped at **1,000 characters**.
25
- - Only clean votes on first-use **Random** prompts move the public leaderboard.
26
- Typed custom prompts are still useful for side-by-side listening, but they do
27
- not affect ratings.
28
-
29
- ## Keeping it fair
30
-
31
- Votes run through an anti-abuse system so the board reflects real preferences:
32
-
33
- - **Behavioral signals** score each vote for risk (timing, patterns, device and
34
- network signals). High-risk votes are recorded but **shadow-excluded** - they
35
- never move the public ratings.
36
- - A lightweight **proof-of-work captcha** appears once per session, and again if
37
- risk rises.
38
- - A background sweep looks for coordinated rings (many accounts sharing an IP or
39
- fingerprint piling onto one model) and per-account bias, retroactively
40
- excluding suspicious votes and recomputing the board from the clean set.
41
-
42
- <Callout>
43
- None of this affects honest voting - it's invisible unless your activity looks
44
- automated or coordinated.
45
- </Callout>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/lib/cn.ts DELETED
@@ -1 +0,0 @@
1
- export { twMerge as cn } from "tailwind-merge";
 
 
apps/docs/lib/layout.shared.tsx DELETED
@@ -1,24 +0,0 @@
1
- import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
2
- import { AudioLines } from "lucide-react";
3
- import { appName, arenaUrl, gitConfig } from "./shared";
4
-
5
- export function baseOptions(): BaseLayoutProps {
6
- return {
7
- nav: {
8
- title: (
9
- <>
10
- <AudioLines className="text-fd-primary size-5" />
11
- <span className="font-semibold">{appName}</span>
12
- </>
13
- ),
14
- },
15
- links: [
16
- {
17
- text: "Open the Arena",
18
- url: arenaUrl,
19
- external: true,
20
- },
21
- ],
22
- githubUrl: `https://github.com/${gitConfig.user}/${gitConfig.repo}`,
23
- };
24
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/lib/shared.ts DELETED
@@ -1,14 +0,0 @@
1
- export const appName = "TTS Arena Docs";
2
- // Docs are served at the root — there's no separate homepage.
3
- export const docsRoute = "/";
4
- export const docsImageRoute = "/og/docs";
5
- export const docsContentRoute = "/llms.mdx/docs";
6
-
7
- /** The main arena, linked from the docs nav. */
8
- export const arenaUrl = "https://huggingface.co/spaces/TTS-AGI/TTS-Arena-V2";
9
-
10
- export const gitConfig = {
11
- user: "TTS-AGI",
12
- repo: "TTS-Arena",
13
- branch: "main",
14
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/lib/source.ts DELETED
@@ -1,37 +0,0 @@
1
- import { docs } from "collections/server";
2
- import { loader } from "fumadocs-core/source";
3
- import { lucideIconsPlugin } from "fumadocs-core/source/lucide-icons";
4
- import { docsContentRoute, docsImageRoute, docsRoute } from "./shared";
5
-
6
- // See https://fumadocs.dev/docs/headless/source-api for more info
7
- export const source = loader({
8
- baseUrl: docsRoute,
9
- source: docs.toFumadocsSource(),
10
- plugins: [lucideIconsPlugin()],
11
- });
12
-
13
- export function getPageImage(page: (typeof source)["$inferPage"]) {
14
- const segments = [...page.slugs, "image.png"];
15
-
16
- return {
17
- segments,
18
- url: `${docsImageRoute}/${segments.join("/")}`,
19
- };
20
- }
21
-
22
- export function getPageMarkdownUrl(page: (typeof source)["$inferPage"]) {
23
- const segments = [...page.slugs, "content.md"];
24
-
25
- return {
26
- segments,
27
- url: `${docsContentRoute}/${segments.join("/")}`,
28
- };
29
- }
30
-
31
- export async function getLLMText(page: (typeof source)["$inferPage"]) {
32
- const processed = await page.data.getText("processed");
33
-
34
- return `# ${page.data.title} (${page.url})
35
-
36
- ${processed}`;
37
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/next.config.mjs DELETED
@@ -1,25 +0,0 @@
1
- import { createMDX } from "fumadocs-mdx/next";
2
-
3
- const withMDX = createMDX();
4
-
5
- /** @type {import('next').NextConfig} */
6
- const config = {
7
- reactStrictMode: true,
8
- // Self-contained server bundle for the Docker/HF Space deploy. Built in
9
- // isolation in Docker (only apps/docs present, no parent workspace), so Next
10
- // traces from the app dir and server.js lands at the standalone root. No
11
- // explicit tracing root — that's only needed for monorepo builds, and a wrong
12
- // value trips Turbopack's workspace-root inference.
13
- output: "standalone",
14
- typescript: {
15
- // The page uses fumadocs' generated MDX page-data fields (body/toc/getText)
16
- // whose types are injected by the MDX plugin at build time and aren't
17
- // visible to a plain tsc pass — it's the template's own pattern. The MDX
18
- // still compiles and renders fine; don't fail the build on this gap. (The
19
- // docs app is content + template boilerplate, so there's little else to
20
- // type-check here anyway.)
21
- ignoreBuildErrors: true,
22
- },
23
- };
24
-
25
- export default withMDX(config);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/package.json DELETED
@@ -1,33 +0,0 @@
1
- {
2
- "name": "@ttsa/docs",
3
- "version": "0.1.0",
4
- "private": true,
5
- "type": "module",
6
- "scripts": {
7
- "build": "fumadocs-mdx && next build --webpack",
8
- "dev": "fumadocs-mdx && next dev",
9
- "start": "next start",
10
- "typecheck": "echo 'docs: typecheck handled by next build (fumadocs MDX types are build-time)'",
11
- "lint": "echo 'no lint for docs'"
12
- },
13
- "dependencies": {
14
- "fumadocs-core": "16.9.3",
15
- "fumadocs-mdx": "15.0.11",
16
- "fumadocs-ui": "16.9.3",
17
- "lucide-react": "^1.17.0",
18
- "next": "16.2.7",
19
- "react": "^19.2.0",
20
- "react-dom": "^19.2.0",
21
- "tailwind-merge": "^3.6.0"
22
- },
23
- "devDependencies": {
24
- "@tailwindcss/postcss": "^4.3.0",
25
- "@types/mdx": "^2.0.13",
26
- "@types/node": "^25.9.1",
27
- "@types/react": "^19.2.0",
28
- "@types/react-dom": "^19.2.0",
29
- "postcss": "^8.5.15",
30
- "tailwindcss": "^4.3.0",
31
- "typescript": "^6.0.3"
32
- }
33
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/postcss.config.mjs DELETED
@@ -1,7 +0,0 @@
1
- const config = {
2
- plugins: {
3
- "@tailwindcss/postcss": {},
4
- },
5
- };
6
-
7
- export default config;
 
 
 
 
 
 
 
 
apps/docs/proxy.ts DELETED
@@ -1,29 +0,0 @@
1
- import { NextRequest, NextResponse } from "next/server";
2
- import { isMarkdownPreferred, rewritePath } from "fumadocs-core/negotiation";
3
- import { docsContentRoute, docsRoute } from "@/lib/shared";
4
-
5
- const { rewrite: rewriteDocs } = rewritePath(
6
- `${docsRoute}{/*path}`,
7
- `${docsContentRoute}{/*path}/content.md`,
8
- );
9
- const { rewrite: rewriteSuffix } = rewritePath(
10
- `${docsRoute}{/*path}.md`,
11
- `${docsContentRoute}{/*path}/content.md`,
12
- );
13
-
14
- export default function proxy(request: NextRequest) {
15
- const result = rewriteSuffix(request.nextUrl.pathname);
16
- if (result) {
17
- return NextResponse.rewrite(new URL(result, request.nextUrl));
18
- }
19
-
20
- if (isMarkdownPreferred(request)) {
21
- const result = rewriteDocs(request.nextUrl.pathname);
22
-
23
- if (result) {
24
- return NextResponse.rewrite(new URL(result, request.nextUrl));
25
- }
26
- }
27
-
28
- return NextResponse.next();
29
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/public/.gitkeep DELETED
@@ -1 +0,0 @@
1
- # Static assets for the docs site.
 
 
apps/docs/source.config.ts DELETED
@@ -1,23 +0,0 @@
1
- import { defineConfig, defineDocs } from "fumadocs-mdx/config";
2
- import { metaSchema, pageSchema } from "fumadocs-core/source/schema";
3
-
4
- // You can customize Zod schemas for frontmatter and `meta.json` here
5
- // see https://fumadocs.dev/docs/mdx/collections
6
- export const docs = defineDocs({
7
- dir: "content/docs",
8
- docs: {
9
- schema: pageSchema,
10
- postprocess: {
11
- includeProcessedMarkdown: true,
12
- },
13
- },
14
- meta: {
15
- schema: metaSchema,
16
- },
17
- });
18
-
19
- export default defineConfig({
20
- mdxOptions: {
21
- // MDX options
22
- },
23
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/docs/tsconfig.json DELETED
@@ -1,35 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ESNext",
4
- "lib": ["dom", "dom.iterable", "esnext"],
5
- "allowJs": true,
6
- "skipLibCheck": true,
7
- "strict": true,
8
- "forceConsistentCasingInFileNames": true,
9
- "noEmit": true,
10
- "esModuleInterop": true,
11
- "module": "esnext",
12
- "moduleResolution": "bundler",
13
- "resolveJsonModule": true,
14
- "isolatedModules": true,
15
- "jsx": "react-jsx",
16
- "incremental": true,
17
- "paths": {
18
- "@/*": ["./*"],
19
- "collections/*": ["./.source/*"]
20
- },
21
- "plugins": [
22
- {
23
- "name": "next"
24
- }
25
- ]
26
- },
27
- "include": [
28
- "next-env.d.ts",
29
- "**/*.ts",
30
- "**/*.tsx",
31
- ".next/types/**/*.ts",
32
- ".next/dev/types/**/*.ts"
33
- ],
34
- "exclude": ["node_modules"]
35
- }