Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Update requirements.txt
#116
by AhmedAshrafMarzouk - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .dockerignore +0 -24
- .env.development.local.example +0 -20
- .env.example +12 -7
- .gitattributes +35 -5
- .github/workflows/ci.yml +0 -52
- .github/workflows/deploy-docs.yml +0 -62
- .github/workflows/deploy-space.yml +0 -119
- .github/workflows/sync-to-hf.yaml +43 -0
- .gitignore +49 -43
- .prettierignore +0 -20
- .prettierrc.json +0 -9
- COPYING +15 -0
- Dockerfile +0 -115
- Dockerfile.docs +0 -46
- LICENSE +15 -197
- LICENSE.APACHE +201 -0
- LICENSE.MIT +21 -0
- README.md +9 -9
- SPACES_DOCS_README.md +0 -19
- TURNSTILE.md +58 -0
- admin.py +891 -0
- app.py +1694 -0
- apps/docs/.gitignore +0 -26
- apps/docs/app/[[...slug]]/page.tsx +0 -67
- apps/docs/app/api/search/route.ts +0 -7
- apps/docs/app/global.css +0 -12
- apps/docs/app/layout.tsx +0 -37
- apps/docs/app/llms-full.txt/route.ts +0 -10
- apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts +0 -26
- apps/docs/app/llms.txt/route.ts +0 -8
- apps/docs/app/og/docs/[...slug]/route.tsx +0 -35
- apps/docs/components/mdx.tsx +0 -15
- apps/docs/content/docs/api.mdx +0 -53
- apps/docs/content/docs/development.mdx +0 -97
- apps/docs/content/docs/index.mdx +0 -46
- apps/docs/content/docs/meta.json +0 -11
- apps/docs/content/docs/ranking.mdx +0 -56
- apps/docs/content/docs/submit-a-model.mdx +0 -38
- apps/docs/content/docs/voting.mdx +0 -45
- apps/docs/lib/cn.ts +0 -1
- apps/docs/lib/layout.shared.tsx +0 -24
- apps/docs/lib/shared.ts +0 -14
- apps/docs/lib/source.ts +0 -37
- apps/docs/next.config.mjs +0 -25
- apps/docs/package.json +0 -33
- apps/docs/postcss.config.mjs +0 -7
- apps/docs/proxy.ts +0 -29
- apps/docs/public/.gitkeep +0 -1
- apps/docs/source.config.ts +0 -23
- 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 |
-
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 2 |
-
|
| 3 |
-
.
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
#
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
#
|
| 18 |
.DS_Store
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
# debug
|
| 22 |
-
npm-debug.log*
|
| 23 |
-
yarn-debug.log*
|
| 24 |
-
*-debug.log*
|
| 25 |
|
| 26 |
-
#
|
| 27 |
-
|
| 28 |
-
!.env.example
|
| 29 |
-
!*.env.example
|
| 30 |
-
!.env.development.local.example
|
| 31 |
|
| 32 |
-
|
| 33 |
-
.vercel
|
| 34 |
|
| 35 |
-
|
| 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 |
-
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 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:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
|
|
|
| 8 |
pinned: true
|
|
|
|
| 9 |
hf_oauth: true
|
| 10 |
-
hf_oauth_scopes:
|
| 11 |
-
- email
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
|
|
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|