Spaces:
Paused
Paused
Upload 6 files
Browse files- README.md +28 -6
- app.py +1170 -416
- requirements-docker.txt +1 -0
- requirements.txt +2 -0
README.md
CHANGED
|
@@ -14,7 +14,7 @@ Deploy the contents of this `HF` folder at the root of your Hugging Face Space.
|
|
| 14 |
|
| 15 |
This profile is CPU-first:
|
| 16 |
|
| 17 |
-
- Normal TTS: Edge, Piper,
|
| 18 |
- Voice clone: Pocket TTS CPU backend
|
| 19 |
- GPU/ZeroGPU: not required
|
| 20 |
|
|
@@ -25,11 +25,33 @@ Set these in Space settings:
|
|
| 25 |
- `VOICECRAFT_AUTH_MODE=license` - requires a live active license and registered device on every `/tts` request.
|
| 26 |
- `LICENSE_VALIDATION_URL` - the deployed Google Apps Script URL. The current production URL is the code default, but setting it explicitly is recommended.
|
| 27 |
- `ENABLE_CLONE_ENGINES=1` - keeps voice clone enabled.
|
| 28 |
-
- `HF_TOKEN` - Hugging Face read token from an account that has accepted the Pocket TTS model terms.
|
| 29 |
-
|
| 30 |
-
`API_SECRET` is legacy compatibility only. Do not put a shared API secret in distributed desktop apps. After every supported desktop build sends `X-License-Key` and `X-Device-ID`, remove the sheet `ApiSecret` value and rotate/remove the old Space `API_SECRET`.
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
- `VOICECRAFT_AUTH_MODE=layered` requires both the live license/device and `API_SECRET`.
|
| 35 |
- `VOICECRAFT_AUTH_MODE=api_secret` is legacy-only and is not recommended for public distribution.
|
|
|
|
| 14 |
|
| 15 |
This profile is CPU-first:
|
| 16 |
|
| 17 |
+
- Normal TTS: Edge cloud voices, curated Piper models, and Silero Russian voices
|
| 18 |
- Voice clone: Pocket TTS CPU backend
|
| 19 |
- GPU/ZeroGPU: not required
|
| 20 |
|
|
|
|
| 25 |
- `VOICECRAFT_AUTH_MODE=license` - requires a live active license and registered device on every `/tts` request.
|
| 26 |
- `LICENSE_VALIDATION_URL` - the deployed Google Apps Script URL. The current production URL is the code default, but setting it explicitly is recommended.
|
| 27 |
- `ENABLE_CLONE_ENGINES=1` - keeps voice clone enabled.
|
| 28 |
+
- `HF_TOKEN` - Hugging Face read token from an account that has accepted the Pocket TTS model terms.
|
| 29 |
+
|
| 30 |
+
`API_SECRET` is legacy compatibility only. Do not put a shared API secret in distributed desktop apps. After every supported desktop build sends `X-License-Key` and `X-Device-ID`, remove the sheet `ApiSecret` value and rotate/remove the old Space `API_SECRET`.
|
| 31 |
+
|
| 32 |
+
Instead of setting many Space secrets, you can set one protected secret named
|
| 33 |
+
`VOICECRAFT_SPACE_CONFIG_JSON`. Individual secrets still work and override this
|
| 34 |
+
combined config when both are present.
|
| 35 |
+
|
| 36 |
+
```json
|
| 37 |
+
{
|
| 38 |
+
"space_id": "razapro857-firebase",
|
| 39 |
+
"auth_mode": "license",
|
| 40 |
+
"max_concurrent_jobs": 1,
|
| 41 |
+
"service_version": "3.1.0",
|
| 42 |
+
"enable_clone_engines": true,
|
| 43 |
+
"hf_token": "hf_xxxxxxxxxxxxxxxxx",
|
| 44 |
+
"firebase_service_account": {
|
| 45 |
+
"type": "service_account",
|
| 46 |
+
"project_id": "voicecraft-1",
|
| 47 |
+
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
|
| 48 |
+
"client_email": "firebase-adminsdk-xxx@voicecraft-1.iam.gserviceaccount.com",
|
| 49 |
+
"token_uri": "https://oauth2.googleapis.com/token"
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Optional modes:
|
| 55 |
|
| 56 |
- `VOICECRAFT_AUTH_MODE=layered` requires both the live license/device and `API_SECRET`.
|
| 57 |
- `VOICECRAFT_AUTH_MODE=api_secret` is legacy-only and is not recommended for public distribution.
|
app.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
import os, io, asyncio, tempfile, threading, re, subprocess, shutil, logging, secrets, sys, platform, math, hashlib, time
|
|
|
|
| 2 |
|
| 3 |
try:
|
| 4 |
import spaces
|
|
@@ -15,6 +16,85 @@ except ImportError:
|
|
| 15 |
|
| 16 |
spaces = _SpacesFallback()
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
import gradio as gr
|
| 19 |
import requests
|
| 20 |
|
|
@@ -24,17 +104,23 @@ from fastapi import Form, Request, HTTPException, UploadFile, File
|
|
| 24 |
from fastapi.responses import StreamingResponse, JSONResponse
|
| 25 |
from fastapi.middleware.gzip import GZipMiddleware
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 28 |
title="VoiceCraft TTS Server",
|
| 29 |
-
description="VoiceCraft desktop
|
| 30 |
-
version=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
)
|
| 32 |
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 33 |
|
| 34 |
|
| 35 |
# â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
| 36 |
-
# SECURITY
|
| 37 |
-
# Production mode validates each license/device with Google Apps Script.
|
| 38 |
# â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
| 39 |
_RAW_API_SECRET = os.environ.get("API_SECRET", "").strip()
|
| 40 |
if _RAW_API_SECRET.startswith("hf_") and not (
|
|
@@ -49,10 +135,7 @@ API_SECRET = (
|
|
| 49 |
or os.environ.get("APP_API_SECRET", "").strip()
|
| 50 |
or ("" if _RAW_API_SECRET.startswith("hf_") else _RAW_API_SECRET)
|
| 51 |
)
|
| 52 |
-
DEFAULT_LICENSE_VALIDATION_URL = (
|
| 53 |
-
"https://script.google.com/macros/s/"
|
| 54 |
-
"AKfycbx6KWT18JHUevX9zXhzsOg40_Ek7wNDhpTIesQ63Lm6aPNCoxhujQL8z_Ll9Dt6-cQ/exec"
|
| 55 |
-
)
|
| 56 |
LICENSE_VALIDATION_URL = (
|
| 57 |
os.environ.get("LICENSE_VALIDATION_URL", "").strip()
|
| 58 |
or DEFAULT_LICENSE_VALIDATION_URL
|
|
@@ -62,11 +145,19 @@ if AUTH_MODE not in {"license", "layered", "api_secret"}:
|
|
| 62 |
AUTH_MODE = "license"
|
| 63 |
MIN_CLIENT_VERSION = os.environ.get("MIN_CLIENT_VERSION", "3.0.0").strip()
|
| 64 |
MAX_CLONE_CHARACTERS = 60_000
|
| 65 |
-
CLONE_CHUNK_CHARACTERS = 1800
|
|
|
|
| 66 |
CLONE_ENGINE_PREFIXES = ("f5tts:",)
|
| 67 |
CLONE_ENGINES = ["f5tts"]
|
| 68 |
CLONE_BACKEND_NAME = "pocket-tts-cpu"
|
| 69 |
-
BASE_ENGINES = ["edge", "piper", "silero"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
def _env_flag(name: str, default=None):
|
|
@@ -76,16 +167,162 @@ def _env_flag(name: str, default=None):
|
|
| 76 |
return raw.strip().lower() in {"1", "true", "yes", "on", "active", "enabled"}
|
| 77 |
|
| 78 |
|
| 79 |
-
def clone_engines_enabled() -> bool:
|
| 80 |
-
explicit = _env_flag("ENABLE_CLONE_ENGINES", None)
|
| 81 |
-
if explicit is not None:
|
| 82 |
-
return explicit
|
| 83 |
-
# CPU clone is now the default backend. Set ENABLE_CLONE_ENGINES=0 for TTS-only Spaces.
|
| 84 |
-
return True
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
def verify_token(request: Request):
|
|
@@ -97,88 +334,127 @@ def verify_token(request: Request):
|
|
| 97 |
raise HTTPException(status_code=403, detail="Unauthorized")
|
| 98 |
|
| 99 |
|
| 100 |
-
_LICENSE_CACHE = {}
|
| 101 |
-
_LICENSE_CACHE_LOCK = threading.Lock()
|
| 102 |
_LICENSE_RATE_STATE = {}
|
| 103 |
_LICENSE_RATE_LOCK = threading.Lock()
|
| 104 |
|
| 105 |
-
|
| 106 |
def _bounded_env_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
| 107 |
try:
|
| 108 |
return max(minimum, min(maximum, int(os.environ.get(name, default))))
|
| 109 |
except (TypeError, ValueError):
|
| 110 |
return default
|
| 111 |
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
return
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
try:
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
raise HTTPException(
|
| 154 |
-
|
| 155 |
-
if
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
raise HTTPException(
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
now = time.monotonic()
|
| 183 |
window = 60.0
|
| 184 |
limit = _bounded_env_int(
|
|
@@ -187,7 +463,7 @@ def _enforce_rate_limit(license_key: str, device_id: str, is_clone: bool):
|
|
| 187 |
1,
|
| 188 |
600,
|
| 189 |
)
|
| 190 |
-
state_key = f"{
|
| 191 |
with _LICENSE_RATE_LOCK:
|
| 192 |
requests_in_window = [
|
| 193 |
timestamp
|
|
@@ -199,27 +475,129 @@ def _enforce_rate_limit(license_key: str, device_id: str, is_clone: bool):
|
|
| 199 |
requests_in_window.append(now)
|
| 200 |
_LICENSE_RATE_STATE[state_key] = requests_in_window
|
| 201 |
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
if AUTH_MODE in {"api_secret", "layered"}:
|
| 205 |
verify_token(request)
|
| 206 |
-
if AUTH_MODE == "api_secret":
|
| 207 |
-
return {}
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
if
|
| 212 |
-
raise HTTPException(status_code=426, detail="VoiceCraft
|
| 213 |
-
if
|
| 214 |
-
raise HTTPException(status_code=
|
| 215 |
-
|
| 216 |
-
raise HTTPException(status_code=400, detail="Invalid license credentials")
|
| 217 |
-
payload = await asyncio.to_thread(_validate_license_sync, license_key, device_id)
|
| 218 |
is_clone = engine in CLONE_ENGINES
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
# â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
| 225 |
# PIPER TTS SETUP — Auto download on first run
|
|
@@ -325,46 +703,7 @@ def download_piper_model(voice_code: str) -> tuple:
|
|
| 325 |
return onnx_path, json_path
|
| 326 |
|
| 327 |
|
| 328 |
-
def
|
| 329 |
-
"""Dynamic Piper voice download — code format: piper:ar_JO-kareem-low"""
|
| 330 |
-
model_name = voice_code.replace("piper:", "") # ar_JO-kareem-low
|
| 331 |
-
# Prevent path traversal
|
| 332 |
-
if ".." in model_name or "/" in model_name or "\\" in model_name:
|
| 333 |
-
raise ValueError(f"Invalid voice code: {voice_code}")
|
| 334 |
-
onnx_file = f"{model_name}.onnx"
|
| 335 |
-
json_file = f"{model_name}.onnx.json"
|
| 336 |
-
onnx_path = os.path.join(PIPER_MODELS_DIR, onnx_file)
|
| 337 |
-
json_path = os.path.join(PIPER_MODELS_DIR, json_file)
|
| 338 |
-
|
| 339 |
-
if os.path.exists(onnx_path) and os.path.exists(json_path):
|
| 340 |
-
return onnx_path, json_path
|
| 341 |
-
|
| 342 |
-
import urllib.request
|
| 343 |
-
# Model format: lang_code-voice_name-quality (e.g., ar_JO-kareem-low)
|
| 344 |
-
# HF path: ar/ar_JO/kareem/low/ar_JO-kareem-low.onnx
|
| 345 |
-
dash_parts = model_name.rsplit("-", 2) # Split from right: ["ar_JO", "kareem", "low"]
|
| 346 |
-
if len(dash_parts) >= 3:
|
| 347 |
-
lang_code = dash_parts[0] # ar_JO
|
| 348 |
-
voice = dash_parts[1] # kareem
|
| 349 |
-
quality = dash_parts[2] # low
|
| 350 |
-
lang = lang_code.split("_")[0] # ar
|
| 351 |
-
hf_path = f"{lang}/{lang_code}/{voice}/{quality}"
|
| 352 |
-
else:
|
| 353 |
-
hf_path = f"{model_name}/{model_name}"
|
| 354 |
-
|
| 355 |
-
for fname, fpath in [(onnx_file, onnx_path), (json_file, json_path)]:
|
| 356 |
-
if not os.path.exists(fpath):
|
| 357 |
-
url = f"{PIPER_BASE_URL}/{hf_path}/{fname}"
|
| 358 |
-
print(f"📥 Downloading dynamic Piper model: {fname}")
|
| 359 |
-
try:
|
| 360 |
-
urllib.request.urlretrieve(url, fpath)
|
| 361 |
-
except Exception as e:
|
| 362 |
-
print(f"âš ï¸ Dynamic Piper download failed: {e}")
|
| 363 |
-
raise
|
| 364 |
-
return onnx_path, json_path
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
def _piper_synth_chunk(text: str, onnx_path: str, json_path: str, length_scale: float) -> bytes:
|
| 368 |
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as out_f:
|
| 369 |
out_path = out_f.name
|
| 370 |
try:
|
|
@@ -391,51 +730,97 @@ def _piper_synth_chunk(text: str, onnx_path: str, json_path: str, length_scale:
|
|
| 391 |
os.unlink(out_path)
|
| 392 |
|
| 393 |
|
| 394 |
-
def
|
| 395 |
-
"""Merge multiple WAV byte chunks using ffmpeg concat (same codec)."""
|
| 396 |
if len(parts) == 1:
|
| 397 |
return parts[0]
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
fd,
|
| 407 |
os.close(fd)
|
| 408 |
-
with open(
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
os.close(fd)
|
| 413 |
-
subprocess.run(["ffmpeg", "-y", "-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
except Exception: pass
|
| 428 |
|
| 429 |
|
| 430 |
def synthesize_piper(text: str, voice_code: str, speed: float = 1.0) -> bytes:
|
| 431 |
if not PIPER_READY:
|
| 432 |
raise Exception("Piper is not available on this system")
|
| 433 |
-
#
|
| 434 |
-
if voice_code in PIPER_VOICES:
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
# Dynamic voice — direct HuggingFace se download
|
| 438 |
-
onnx_path, json_path = download_piper_dynamic(voice_code)
|
| 439 |
length_scale = 1.0 / max(0.25, min(4.0, speed))
|
| 440 |
# Lambi text ko chunk karo (Piper stdin limit + timeout avoid karne ke liye)
|
| 441 |
if len(text) > 1400:
|
|
@@ -453,13 +838,21 @@ def synthesize_piper(text: str, voice_code: str, speed: float = 1.0) -> bytes:
|
|
| 453 |
return _ffmpeg_concat_wav(parts)
|
| 454 |
|
| 455 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
def split_text(text: str, max_chars: int = 1400) -> list:
|
| 457 |
"""Text ko chunklara bol"""
|
| 458 |
-
text =
|
| 459 |
if not text:
|
| 460 |
return []
|
| 461 |
sentence_re = re.compile(
|
| 462 |
-
r'(?
|
| 463 |
)
|
| 464 |
chunks, current = [], ""
|
| 465 |
for para in re.split(r'\n+', text):
|
|
@@ -602,48 +995,13 @@ def synthesize_silero(text: str, voice_code: str) -> bytes:
|
|
| 602 |
|
| 603 |
|
| 604 |
def _ffmpeg_concat_mp3(parts: list) -> bytes:
|
| 605 |
-
|
| 606 |
-
if len(parts) == 1:
|
| 607 |
-
return parts[0]
|
| 608 |
-
tmp_files = []
|
| 609 |
-
concat_list = None
|
| 610 |
-
out_path = None
|
| 611 |
-
try:
|
| 612 |
-
for data in parts:
|
| 613 |
-
fd, path = tempfile.mkstemp(suffix=".mp3")
|
| 614 |
-
os.close(fd)
|
| 615 |
-
with open(path, "wb") as f:
|
| 616 |
-
f.write(data)
|
| 617 |
-
tmp_files.append(path)
|
| 618 |
-
fd, concat_list = tempfile.mkstemp(suffix=".txt")
|
| 619 |
-
os.close(fd)
|
| 620 |
-
with open(concat_list, "w") as f:
|
| 621 |
-
for p in tmp_files:
|
| 622 |
-
f.write(f"file '{p}'\n")
|
| 623 |
-
fd, out_path = tempfile.mkstemp(suffix=".mp3")
|
| 624 |
-
os.close(fd)
|
| 625 |
-
subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
| 626 |
-
"-f", "concat", "-safe", "0", "-i", concat_list,
|
| 627 |
-
"-c", "copy", out_path], check=True, timeout=60, creationflags=CREATE_NO_WINDOW)
|
| 628 |
-
with open(out_path, "rb") as f:
|
| 629 |
-
return f.read()
|
| 630 |
-
except Exception:
|
| 631 |
-
return b"".join(parts)
|
| 632 |
-
finally:
|
| 633 |
-
for p in tmp_files:
|
| 634 |
-
try: os.unlink(p)
|
| 635 |
-
except Exception: pass
|
| 636 |
-
if concat_list:
|
| 637 |
-
try: os.unlink(concat_list)
|
| 638 |
-
except Exception: pass
|
| 639 |
-
if out_path:
|
| 640 |
-
try: os.unlink(out_path)
|
| 641 |
-
except Exception: pass
|
| 642 |
|
| 643 |
|
| 644 |
async def _edge_synth_chunk(chunk: str, voice: str, kwargs: dict) -> bytes:
|
| 645 |
"""One chunk ki audio lao, 3 baar retry karo. Fail par b"" return."""
|
| 646 |
import edge_tts
|
|
|
|
| 647 |
for attempt in range(3):
|
| 648 |
data = bytearray()
|
| 649 |
try:
|
|
@@ -655,8 +1013,18 @@ async def _edge_synth_chunk(chunk: str, voice: str, kwargs: dict) -> bytes:
|
|
| 655 |
return bytes(data)
|
| 656 |
except Exception:
|
| 657 |
if attempt == 2:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
return b""
|
| 659 |
-
await asyncio.sleep(
|
| 660 |
return b""
|
| 661 |
|
| 662 |
|
|
@@ -701,9 +1069,10 @@ CLONE_MODELS = {
|
|
| 701 |
"f5tts:v1_base": ("f5tts", "multilingual", "Voice Clone"),
|
| 702 |
}
|
| 703 |
|
| 704 |
-
_POCKET_MODEL = None
|
| 705 |
-
_POCKET_MODEL_ERROR = None
|
| 706 |
-
|
|
|
|
| 707 |
_POCKET_INFER_LOCK = threading.Lock()
|
| 708 |
_POCKET_RESULT_CACHE = {}
|
| 709 |
_POCKET_RESULT_CACHE_ORDER = []
|
|
@@ -712,8 +1081,9 @@ _POCKET_RESULT_CACHE_LIMIT = 8
|
|
| 712 |
_POCKET_RESULT_CACHE_MAX_BYTES = 16 * 1024 * 1024
|
| 713 |
_POCKET_VOICE_STATE_CACHE = {}
|
| 714 |
_POCKET_VOICE_STATE_ORDER = []
|
| 715 |
-
_POCKET_VOICE_STATE_LOCK = threading.Lock()
|
| 716 |
-
_POCKET_VOICE_STATE_LIMIT = 6
|
|
|
|
| 717 |
|
| 718 |
|
| 719 |
def _hf_token_configured() -> bool:
|
|
@@ -731,20 +1101,42 @@ def _pocket_clone_auth_error() -> str:
|
|
| 731 |
)
|
| 732 |
|
| 733 |
|
| 734 |
-
def _pocket_clone_ready() -> bool:
|
| 735 |
-
return bool(_POCKET_MODEL is not None and getattr(_POCKET_MODEL, "has_voice_cloning", False))
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
def
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
_POCKET_MODEL_ERROR = None
|
| 749 |
if getattr(_POCKET_MODEL, "has_voice_cloning", False):
|
| 750 |
print("Pocket TTS CPU voice clone ready")
|
|
@@ -822,6 +1214,43 @@ def _normalize_clone_reference(reference_path: str) -> str:
|
|
| 822 |
"Reference audio could not be decoded. "
|
| 823 |
f"Use a clear WAV, MP3, M4A, OGG, or FLAC file. {detail[:160]}"
|
| 824 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 825 |
return normalized_path
|
| 826 |
except Exception:
|
| 827 |
try:
|
|
@@ -838,7 +1267,14 @@ def _clone_request_key(text: str, reference_key: str) -> str:
|
|
| 838 |
|
| 839 |
def _get_cached_clone(cache_key: str):
|
| 840 |
with _POCKET_RESULT_CACHE_LOCK:
|
| 841 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 842 |
|
| 843 |
|
| 844 |
def _cache_clone(cache_key: str, audio: bytes):
|
|
@@ -856,10 +1292,17 @@ def _cache_clone(cache_key: str, audio: bytes):
|
|
| 856 |
|
| 857 |
def _get_cached_voice_state(reference_key: str):
|
| 858 |
with _POCKET_VOICE_STATE_LOCK:
|
| 859 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
|
| 861 |
|
| 862 |
-
def _cache_voice_state(reference_key: str, voice_state):
|
| 863 |
with _POCKET_VOICE_STATE_LOCK:
|
| 864 |
if reference_key in _POCKET_VOICE_STATE_CACHE:
|
| 865 |
_POCKET_VOICE_STATE_ORDER.remove(reference_key)
|
|
@@ -867,11 +1310,27 @@ def _cache_voice_state(reference_key: str, voice_state):
|
|
| 867 |
_POCKET_VOICE_STATE_ORDER.append(reference_key)
|
| 868 |
while len(_POCKET_VOICE_STATE_ORDER) > _POCKET_VOICE_STATE_LIMIT:
|
| 869 |
oldest = _POCKET_VOICE_STATE_ORDER.pop(0)
|
| 870 |
-
_POCKET_VOICE_STATE_CACHE.pop(oldest, None)
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
if not reference_path or not os.path.exists(reference_path):
|
| 876 |
raise ValueError("A reference audio file is required for voice cloning")
|
| 877 |
model = _load_pocket_model()
|
|
@@ -892,13 +1351,36 @@ def _run_pocket_clone_cpu(text: str, reference_path: str, reference_key: str) ->
|
|
| 892 |
raise
|
| 893 |
_cache_voice_state(reference_key, voice_state)
|
| 894 |
chunks = split_text(text, max_chars=CLONE_CHUNK_CHARACTERS)
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 902 |
if not audio_parts:
|
| 903 |
raise RuntimeError("Voice clone model did not produce audio")
|
| 904 |
if len(audio_parts) == 1:
|
|
@@ -919,58 +1401,100 @@ def _run_pocket_clone_cpu(text: str, reference_path: str, reference_key: str) ->
|
|
| 919 |
return buf.getvalue()
|
| 920 |
|
| 921 |
|
| 922 |
-
async def synthesize_f5tts(text: str, reference_path: str = None) -> bytes:
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 935 |
)
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
style=style or fallback_style,
|
| 968 |
-
)
|
| 969 |
|
| 970 |
@app.post("/tts")
|
| 971 |
async def tts_endpoint(
|
| 972 |
request: Request,
|
| 973 |
-
engine: str = Form(...), # "edge" | "piper" | "silero" | "
|
| 974 |
text: str = Form(...),
|
| 975 |
voice: str = Form("en-US-AvaNeural"), # edge voice code OR piper/silero code
|
| 976 |
rate: str = Form("+0%"), # edge only
|
|
@@ -979,13 +1503,14 @@ async def tts_endpoint(
|
|
| 979 |
speed: float = Form(1.0), # piper only
|
| 980 |
style: str = Form(None), # edge style (emotion)
|
| 981 |
styledegree: str = Form(None), # edge style degree 0-2
|
|
|
|
| 982 |
voice_cloning: bool = Form(False), # voice cloning toggle
|
| 983 |
reference_audio: UploadFile = File(None),
|
| 984 |
):
|
| 985 |
if not text or not text.strip():
|
| 986 |
return JSONResponse(status_code=400, content={"error": "Text is empty"})
|
| 987 |
|
| 988 |
-
text = text.strip()
|
| 989 |
|
| 990 |
# Abuse / timeout guard — lambi text Chapter Mode se bhejo
|
| 991 |
if len(text) > 60000:
|
|
@@ -994,14 +1519,35 @@ async def tts_endpoint(
|
|
| 994 |
content={"error": "Text too long (max 60000 chars). Use Chapter Mode for longer text."},
|
| 995 |
)
|
| 996 |
|
| 997 |
-
engine = engine.strip().lower()
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1005 |
if engine in CLONE_ENGINES and len(text) > MAX_CLONE_CHARACTERS:
|
| 1006 |
return JSONResponse(
|
| 1007 |
status_code=413,
|
|
@@ -1013,9 +1559,14 @@ async def tts_endpoint(
|
|
| 1013 |
},
|
| 1014 |
)
|
| 1015 |
|
| 1016 |
-
reference_path = None
|
| 1017 |
-
|
| 1018 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1019 |
suffix = os.path.splitext(reference_audio.filename)[1].lower() or ".wav"
|
| 1020 |
if suffix not in (".wav", ".mp3", ".m4a", ".ogg", ".flac"):
|
| 1021 |
return JSONResponse(status_code=400, content={"error": "Unsupported reference audio format"})
|
|
@@ -1052,15 +1603,17 @@ async def tts_endpoint(
|
|
| 1052 |
media = "audio/wav"
|
| 1053 |
fname = "tts_f5_clone.wav"
|
| 1054 |
|
| 1055 |
-
elif engine == "anime":
|
| 1056 |
-
audio = await synthesize_anime(text, voice, style)
|
| 1057 |
-
media = "audio/mpeg"
|
| 1058 |
-
fname = "tts_anime.mp3"
|
| 1059 |
-
|
| 1060 |
else:
|
| 1061 |
return JSONResponse(status_code=400, content={"error": f"Unknown engine: {engine}"})
|
| 1062 |
|
| 1063 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1064 |
io.BytesIO(audio),
|
| 1065 |
media_type=media,
|
| 1066 |
headers={"Content-Disposition": f"attachment; filename={fname}"},
|
|
@@ -1070,53 +1623,288 @@ async def tts_endpoint(
|
|
| 1070 |
logging.error(f"TTS error: {e}", exc_info=True)
|
| 1071 |
return JSONResponse(
|
| 1072 |
status_code=500,
|
| 1073 |
-
content={"error":
|
| 1074 |
)
|
| 1075 |
-
finally:
|
| 1076 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1077 |
if reference_path and os.path.exists(reference_path):
|
| 1078 |
os.unlink(reference_path)
|
| 1079 |
except Exception:
|
| 1080 |
pass
|
| 1081 |
|
| 1082 |
|
| 1083 |
-
|
| 1084 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1085 |
return {
|
| 1086 |
-
"status": "
|
| 1087 |
-
"
|
| 1088 |
-
"
|
|
|
|
|
|
|
|
|
|
| 1089 |
}
|
| 1090 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1091 |
|
| 1092 |
-
@app.get("/health")
|
| 1093 |
-
@app.head("/health")
|
| 1094 |
-
def health():
|
| 1095 |
return {
|
| 1096 |
-
"
|
| 1097 |
-
"
|
| 1098 |
-
"
|
| 1099 |
-
"
|
| 1100 |
-
"
|
| 1101 |
-
"
|
| 1102 |
-
"
|
| 1103 |
-
"
|
| 1104 |
-
"
|
| 1105 |
-
"
|
| 1106 |
-
"license_auth_required": AUTH_MODE in {"license", "layered"},
|
| 1107 |
-
"clone_model_ready": _pocket_clone_ready(),
|
| 1108 |
-
"clone_auth_configured": _hf_token_configured(),
|
| 1109 |
-
"clone_setup_required": clone_engines_enabled() and not _pocket_clone_ready(),
|
| 1110 |
-
"clone_reference_cache_entries": len(_POCKET_VOICE_STATE_CACHE),
|
| 1111 |
}
|
| 1112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1113 |
|
| 1114 |
@app.get("/all_voices")
|
| 1115 |
-
async def all_voices_list():
|
| 1116 |
-
"""All
|
|
|
|
| 1117 |
result = {"edge": {}, "piper": {}, "silero": {}}
|
| 1118 |
|
| 1119 |
-
# Edge
|
| 1120 |
try:
|
| 1121 |
import edge_tts
|
| 1122 |
voices = await edge_tts.list_voices()
|
|
@@ -1138,40 +1926,26 @@ async def all_voices_list():
|
|
| 1138 |
except Exception as e:
|
| 1139 |
print(f"Edge voices error: {e}")
|
| 1140 |
|
| 1141 |
-
# Piper
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
quality = dash_parts[2]
|
| 1162 |
-
if quality in ("low", "x_low"):
|
| 1163 |
-
continue
|
| 1164 |
-
quality_map = {"high": " +", "medium": "", "low": " -", "x_low": " --"}
|
| 1165 |
-
qs = quality_map.get(quality, " -")
|
| 1166 |
-
display = f"{voice} [{lang_code}]{qs}"
|
| 1167 |
-
else:
|
| 1168 |
-
display = model_name.replace("_", " ").title()
|
| 1169 |
-
full_code = f"piper:{model_name}"
|
| 1170 |
-
result["piper"][display] = full_code
|
| 1171 |
-
except Exception as e:
|
| 1172 |
-
print(f"Piper dynamic fetch error: {e}")
|
| 1173 |
-
for key in PIPER_VOICES:
|
| 1174 |
-
result["piper"][key] = key # voice code as string, not tuple
|
| 1175 |
|
| 1176 |
# Silero v4 — Russian (official v4_ru speakers)
|
| 1177 |
silero_ru_speakers = {
|
|
@@ -1181,17 +1955,9 @@ async def all_voices_list():
|
|
| 1181 |
for speaker_code, display_name in silero_ru_speakers.items():
|
| 1182 |
result["silero"][f"{display_name} \u2022 Russian [RU]"] = f"silero:ru_{speaker_code}"
|
| 1183 |
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
"
|
| 1187 |
-
"Anime Edge [ja]": "anime:ja_edge",
|
| 1188 |
-
"Anime Piper [zh]": "anime:zh_piper",
|
| 1189 |
-
}
|
| 1190 |
-
result["anime"] = anime_voices
|
| 1191 |
-
|
| 1192 |
-
if not clone_engines_enabled():
|
| 1193 |
-
total = sum(len(group) for group in result.values())
|
| 1194 |
-
return {"voices": result, "total": total, "clone_enabled": False}
|
| 1195 |
|
| 1196 |
result["f5tts"] = {"Voice Clone": "f5tts:v1_base"}
|
| 1197 |
total = sum(len(group) for group in result.values())
|
|
@@ -1202,7 +1968,6 @@ GRADIO_DEFAULT_VOICES = {
|
|
| 1202 |
"edge": "en-US-AvaNeural",
|
| 1203 |
"piper": "piper:en_US-amy-medium",
|
| 1204 |
"silero": "silero:ru_xenia",
|
| 1205 |
-
"anime": "anime:en_whisper",
|
| 1206 |
"f5tts": "f5tts:v1_base",
|
| 1207 |
}
|
| 1208 |
|
|
@@ -1282,10 +2047,6 @@ def _build_gradio_ui(gr):
|
|
| 1282 |
audio = await asyncio.to_thread(synthesize_silero, clean_text, voice)
|
| 1283 |
return _write_audio_file(audio, ".wav"), "Ready - Silero audio generated."
|
| 1284 |
|
| 1285 |
-
if engine == "anime":
|
| 1286 |
-
audio = await synthesize_anime(clean_text, voice, style)
|
| 1287 |
-
return _write_audio_file(audio, ".mp3"), "Ready - anime-style audio generated."
|
| 1288 |
-
|
| 1289 |
if engine == "f5tts":
|
| 1290 |
audio = await synthesize_f5tts(clean_text, reference_path=reference_path)
|
| 1291 |
return _write_audio_file(audio, ".wav"), "Ready - voice clone generated."
|
|
@@ -1349,14 +2110,7 @@ def _build_gradio_ui(gr):
|
|
| 1349 |
|
| 1350 |
@app.get("/")
|
| 1351 |
def root():
|
| 1352 |
-
return {
|
| 1353 |
-
"name": "VoiceCraft TTS Server",
|
| 1354 |
-
"status": "running",
|
| 1355 |
-
"clone_enabled": clone_engines_enabled(),
|
| 1356 |
-
"zerogpu_enabled": False,
|
| 1357 |
-
"clone_backend": CLONE_BACKEND_NAME,
|
| 1358 |
-
"endpoints": ["/tts", "/health", "/status", "/all_voices"],
|
| 1359 |
-
}
|
| 1360 |
|
| 1361 |
|
| 1362 |
if __name__ == "__main__":
|
|
@@ -1365,5 +2119,5 @@ if __name__ == "__main__":
|
|
| 1365 |
server_name="0.0.0.0",
|
| 1366 |
server_port=port,
|
| 1367 |
share=False,
|
| 1368 |
-
show_error=
|
| 1369 |
)
|
|
|
|
| 1 |
+
import os, io, asyncio, tempfile, threading, re, subprocess, shutil, logging, secrets, sys, platform, math, hashlib, time, json
|
| 2 |
+
from contextlib import nullcontext
|
| 3 |
|
| 4 |
try:
|
| 5 |
import spaces
|
|
|
|
| 16 |
|
| 17 |
spaces = _SpacesFallback()
|
| 18 |
|
| 19 |
+
try:
|
| 20 |
+
import firebase_admin
|
| 21 |
+
from firebase_admin import auth as fb_auth, firestore, credentials
|
| 22 |
+
HAS_FIREBASE = True
|
| 23 |
+
except Exception:
|
| 24 |
+
HAS_FIREBASE = False
|
| 25 |
+
firebase_admin = None
|
| 26 |
+
fb_auth = None
|
| 27 |
+
firestore = None
|
| 28 |
+
credentials = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _apply_space_config_secret():
|
| 32 |
+
"""Allow one protected HF secret to populate the usual env settings."""
|
| 33 |
+
raw = os.environ.get("VOICECRAFT_SPACE_CONFIG_JSON", "").strip()
|
| 34 |
+
if not raw:
|
| 35 |
+
return
|
| 36 |
+
try:
|
| 37 |
+
config = json.loads(raw)
|
| 38 |
+
if not isinstance(config, dict):
|
| 39 |
+
raise ValueError("VOICECRAFT_SPACE_CONFIG_JSON must be a JSON object")
|
| 40 |
+
except Exception as exc:
|
| 41 |
+
print("Space config JSON notice:", exc)
|
| 42 |
+
return
|
| 43 |
+
|
| 44 |
+
def set_default(env_name, *keys, transform=str):
|
| 45 |
+
if os.environ.get(env_name):
|
| 46 |
+
return
|
| 47 |
+
for key in keys:
|
| 48 |
+
if key in config and config[key] not in (None, ""):
|
| 49 |
+
os.environ[env_name] = transform(config[key])
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
firebase_json = (
|
| 53 |
+
config.get("firebase_service_account_json")
|
| 54 |
+
or config.get("firebase_admin_json")
|
| 55 |
+
or config.get("firebase_service_account")
|
| 56 |
+
or config.get("service_account")
|
| 57 |
+
)
|
| 58 |
+
if firebase_json and not os.environ.get("FIREBASE_SERVICE_ACCOUNT_JSON"):
|
| 59 |
+
os.environ["FIREBASE_SERVICE_ACCOUNT_JSON"] = (
|
| 60 |
+
firebase_json if isinstance(firebase_json, str) else json.dumps(firebase_json)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
set_default("VOICECRAFT_SPACE_ID", "space_id", "voicecraft_space_id")
|
| 64 |
+
set_default("VOICECRAFT_AUTH_MODE", "auth_mode", "voicecraft_auth_mode")
|
| 65 |
+
set_default("VOICECRAFT_MAX_CONCURRENT_JOBS", "max_concurrent_jobs", "voicecraft_max_concurrent_jobs")
|
| 66 |
+
set_default("VOICECRAFT_SERVICE_VERSION", "service_version", "voicecraft_service_version")
|
| 67 |
+
set_default("MIN_CLIENT_VERSION", "min_client_version", "minimum_version")
|
| 68 |
+
set_default("ENABLE_CLONE_ENGINES", "enable_clone_engines", "clone_enabled", transform=lambda value: "true" if bool(value) else "false")
|
| 69 |
+
set_default("HF_TOKEN", "hf_token", "huggingface_token")
|
| 70 |
+
set_default("VOICECRAFT_API_SECRET", "api_secret", "voicecraft_api_secret")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
_apply_space_config_secret()
|
| 74 |
+
|
| 75 |
+
_fb_app = None
|
| 76 |
+
_firestore_db = None
|
| 77 |
+
def _init_firebase():
|
| 78 |
+
global _fb_app, _firestore_db
|
| 79 |
+
if not HAS_FIREBASE:
|
| 80 |
+
return None
|
| 81 |
+
if _fb_app is None:
|
| 82 |
+
try:
|
| 83 |
+
credential_json = os.environ.get("FIREBASE_SERVICE_ACCOUNT_JSON", "").strip()
|
| 84 |
+
cred_path = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS', '').strip()
|
| 85 |
+
if credential_json:
|
| 86 |
+
cred = credentials.Certificate(json.loads(credential_json))
|
| 87 |
+
_fb_app = firebase_admin.initialize_app(cred)
|
| 88 |
+
_firestore_db = firestore.client()
|
| 89 |
+
elif cred_path and os.path.exists(cred_path):
|
| 90 |
+
cred = credentials.Certificate(cred_path)
|
| 91 |
+
_fb_app = firebase_admin.initialize_app(cred)
|
| 92 |
+
_firestore_db = firestore.client()
|
| 93 |
+
except Exception as e:
|
| 94 |
+
print("Firebase init notice:", e)
|
| 95 |
+
return None
|
| 96 |
+
return _firestore_db
|
| 97 |
+
|
| 98 |
import gradio as gr
|
| 99 |
import requests
|
| 100 |
|
|
|
|
| 104 |
from fastapi.responses import StreamingResponse, JSONResponse
|
| 105 |
from fastapi.middleware.gzip import GZipMiddleware
|
| 106 |
|
| 107 |
+
SERVICE_VERSION = os.environ.get("VOICECRAFT_SERVICE_VERSION", "3.1.0").strip()
|
| 108 |
+
|
| 109 |
+
app = gr.Server(
|
| 110 |
+
debug=False,
|
| 111 |
title="VoiceCraft TTS Server",
|
| 112 |
+
description="VoiceCraft desktop service.",
|
| 113 |
+
version=SERVICE_VERSION,
|
| 114 |
+
docs_url=None,
|
| 115 |
+
redoc_url=None,
|
| 116 |
+
openapi_url=None,
|
| 117 |
+
enable_monitoring=False,
|
| 118 |
)
|
| 119 |
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 120 |
|
| 121 |
|
| 122 |
# â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
| 123 |
+
# SECURITY — server-side Firebase authorization
|
|
|
|
| 124 |
# â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
| 125 |
_RAW_API_SECRET = os.environ.get("API_SECRET", "").strip()
|
| 126 |
if _RAW_API_SECRET.startswith("hf_") and not (
|
|
|
|
| 135 |
or os.environ.get("APP_API_SECRET", "").strip()
|
| 136 |
or ("" if _RAW_API_SECRET.startswith("hf_") else _RAW_API_SECRET)
|
| 137 |
)
|
| 138 |
+
DEFAULT_LICENSE_VALIDATION_URL = os.environ.get("DEFAULT_LICENSE_VALIDATION_URL", "").strip()
|
|
|
|
|
|
|
|
|
|
| 139 |
LICENSE_VALIDATION_URL = (
|
| 140 |
os.environ.get("LICENSE_VALIDATION_URL", "").strip()
|
| 141 |
or DEFAULT_LICENSE_VALIDATION_URL
|
|
|
|
| 145 |
AUTH_MODE = "license"
|
| 146 |
MIN_CLIENT_VERSION = os.environ.get("MIN_CLIENT_VERSION", "3.0.0").strip()
|
| 147 |
MAX_CLONE_CHARACTERS = 60_000
|
| 148 |
+
CLONE_CHUNK_CHARACTERS = 1800
|
| 149 |
+
CLONE_RETRY_CHUNK_CHARACTERS = 900
|
| 150 |
CLONE_ENGINE_PREFIXES = ("f5tts:",)
|
| 151 |
CLONE_ENGINES = ["f5tts"]
|
| 152 |
CLONE_BACKEND_NAME = "pocket-tts-cpu"
|
| 153 |
+
BASE_ENGINES = ["edge", "piper", "silero"]
|
| 154 |
+
SPACE_ID = os.environ.get("VOICECRAFT_SPACE_ID", "").strip() or os.environ.get("SPACE_ID", "").strip() or "unregistered"
|
| 155 |
+
MAX_CONCURRENT_JOBS = max(1, min(int(os.environ.get("VOICECRAFT_MAX_CONCURRENT_JOBS", "1")), 20))
|
| 156 |
+
_SPACE_CAPACITY = threading.BoundedSemaphore(MAX_CONCURRENT_JOBS)
|
| 157 |
+
_SPACE_STATE_LOCK = threading.Lock()
|
| 158 |
+
_ACTIVE_JOBS = 0
|
| 159 |
+
_POLICY_LOCK = threading.Lock()
|
| 160 |
+
_POLICY_CACHE = {"loaded_at": 0.0, "maintenance_mode": False, "minimum_version": MIN_CLIENT_VERSION}
|
| 161 |
|
| 162 |
|
| 163 |
def _env_flag(name: str, default=None):
|
|
|
|
| 167 |
return raw.strip().lower() in {"1", "true", "yes", "on", "active", "enabled"}
|
| 168 |
|
| 169 |
|
| 170 |
+
def clone_engines_enabled() -> bool:
|
| 171 |
+
explicit = _env_flag("ENABLE_CLONE_ENGINES", None)
|
| 172 |
+
if explicit is not None:
|
| 173 |
+
return explicit
|
| 174 |
+
# CPU clone is now the default backend. Set ENABLE_CLONE_ENGINES=0 for TTS-only Spaces.
|
| 175 |
+
return True
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def clone_backend_available() -> bool:
|
| 179 |
+
if not clone_engines_enabled():
|
| 180 |
+
return False
|
| 181 |
+
if _POCKET_MODEL_ERROR or _POCKET_GENERATION_ERROR:
|
| 182 |
+
return False
|
| 183 |
+
if _POCKET_MODEL is not None:
|
| 184 |
+
return bool(getattr(_POCKET_MODEL, "has_voice_cloning", False))
|
| 185 |
+
return _hf_token_configured()
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def clone_backend_status() -> dict:
|
| 189 |
+
if not clone_engines_enabled():
|
| 190 |
+
return {"enabled": False, "ready": False, "message": "disabled"}
|
| 191 |
+
if _POCKET_GENERATION_ERROR:
|
| 192 |
+
return {"enabled": False, "ready": False, "message": "generation_failed"}
|
| 193 |
+
if _POCKET_MODEL is not None:
|
| 194 |
+
ready = bool(getattr(_POCKET_MODEL, "has_voice_cloning", False))
|
| 195 |
+
return {
|
| 196 |
+
"enabled": ready,
|
| 197 |
+
"ready": ready,
|
| 198 |
+
"message": "ready" if ready else "model_loaded_without_clone_weights",
|
| 199 |
+
}
|
| 200 |
+
if _POCKET_MODEL_ERROR:
|
| 201 |
+
return {"enabled": False, "ready": False, "message": "model_startup_failed"}
|
| 202 |
+
if not _hf_token_configured():
|
| 203 |
+
return {"enabled": False, "ready": False, "message": "hf_token_required"}
|
| 204 |
+
return {"enabled": True, "ready": False, "message": "loading"}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def available_engines():
|
| 208 |
+
return BASE_ENGINES + (CLONE_ENGINES if clone_backend_available() else [])
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def get_runtime_policy():
|
| 212 |
+
now = time.monotonic()
|
| 213 |
+
with _POLICY_LOCK:
|
| 214 |
+
if now - float(_POLICY_CACHE.get("loaded_at", 0.0)) < 30:
|
| 215 |
+
return dict(_POLICY_CACHE)
|
| 216 |
+
policy = {
|
| 217 |
+
"loaded_at": now,
|
| 218 |
+
"maintenance_mode": False,
|
| 219 |
+
"minimum_version": MIN_CLIENT_VERSION,
|
| 220 |
+
"space_enabled": True,
|
| 221 |
+
"space_clone_enabled": clone_backend_available(),
|
| 222 |
+
}
|
| 223 |
+
db = _init_firebase()
|
| 224 |
+
if db is not None:
|
| 225 |
+
try:
|
| 226 |
+
runtime_doc = db.collection("public_config").document("runtime").get()
|
| 227 |
+
if runtime_doc.exists:
|
| 228 |
+
runtime = runtime_doc.to_dict() or {}
|
| 229 |
+
policy["maintenance_mode"] = bool(runtime.get("maintenance_mode", False))
|
| 230 |
+
policy["minimum_version"] = str(runtime.get("minimum_version") or MIN_CLIENT_VERSION)
|
| 231 |
+
if SPACE_ID != "unregistered":
|
| 232 |
+
space_doc = db.collection("spaces").document(SPACE_ID).get()
|
| 233 |
+
if space_doc.exists:
|
| 234 |
+
space_data = space_doc.to_dict() or {}
|
| 235 |
+
policy["space_enabled"] = bool(space_data.get("enabled", False))
|
| 236 |
+
policy["space_clone_enabled"] = bool(space_data.get("clone_enabled", False)) and clone_backend_available()
|
| 237 |
+
except Exception:
|
| 238 |
+
pass
|
| 239 |
+
with _POLICY_LOCK:
|
| 240 |
+
_POLICY_CACHE.clear()
|
| 241 |
+
_POLICY_CACHE.update(policy)
|
| 242 |
+
return dict(policy)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _clean_hf_space_url(value):
|
| 246 |
+
raw = str(value or "").strip().rstrip("/")
|
| 247 |
+
if not raw.startswith("https://"):
|
| 248 |
+
return ""
|
| 249 |
+
host = raw.split("/", 3)[2].lower()
|
| 250 |
+
if host == "hf.space" or host.endswith(".hf.space") or host == "huggingface.co" or host.endswith(".huggingface.co"):
|
| 251 |
+
return raw
|
| 252 |
+
return ""
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _space_public_payload(snapshot_id, item):
|
| 256 |
+
url = _clean_hf_space_url((item or {}).get("url"))
|
| 257 |
+
if not url:
|
| 258 |
+
return None
|
| 259 |
+
return {
|
| 260 |
+
"id": str(snapshot_id or "")[:80],
|
| 261 |
+
"name": str((item or {}).get("name") or snapshot_id or "")[:80],
|
| 262 |
+
"url": url,
|
| 263 |
+
"clone_enabled": bool((item or {}).get("clone_enabled", False)),
|
| 264 |
+
"priority": int((item or {}).get("priority", 100) or 100),
|
| 265 |
+
"max_concurrent_jobs": max(1, int((item or {}).get("max_concurrent_jobs", 1) or 1)),
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _license_space_pool(db, uid, user_data, license_key, lic):
|
| 270 |
+
"""Return only the Spaces this signed-in user may see/use."""
|
| 271 |
+
if db is None:
|
| 272 |
+
return []
|
| 273 |
+
|
| 274 |
+
assigned_ids = set()
|
| 275 |
+
assigned_urls = set()
|
| 276 |
+
for source in (user_data or {}, lic or {}):
|
| 277 |
+
for key in (
|
| 278 |
+
"space_ids",
|
| 279 |
+
"clone_space_ids",
|
| 280 |
+
"dedicated_space_ids",
|
| 281 |
+
"assigned_space_ids",
|
| 282 |
+
):
|
| 283 |
+
for value in source.get(key) or []:
|
| 284 |
+
if value:
|
| 285 |
+
assigned_ids.add(str(value).strip())
|
| 286 |
+
for key in (
|
| 287 |
+
"spaces",
|
| 288 |
+
"clone_spaces",
|
| 289 |
+
"dedicated_spaces",
|
| 290 |
+
"assigned_spaces",
|
| 291 |
+
):
|
| 292 |
+
for value in source.get(key) or []:
|
| 293 |
+
if isinstance(value, dict):
|
| 294 |
+
payload = _space_public_payload(value.get("id") or value.get("name"), value)
|
| 295 |
+
if payload:
|
| 296 |
+
assigned_urls.add(payload["url"])
|
| 297 |
+
else:
|
| 298 |
+
url = _clean_hf_space_url(value)
|
| 299 |
+
if url:
|
| 300 |
+
assigned_urls.add(url)
|
| 301 |
+
|
| 302 |
+
dedicated = []
|
| 303 |
+
auto_pool = []
|
| 304 |
+
try:
|
| 305 |
+
for snapshot in db.collection("spaces").where("enabled", "==", True).stream():
|
| 306 |
+
item = snapshot.to_dict() or {}
|
| 307 |
+
payload = _space_public_payload(snapshot.id, item)
|
| 308 |
+
if not payload:
|
| 309 |
+
continue
|
| 310 |
+
is_dedicated = (
|
| 311 |
+
snapshot.id in assigned_ids
|
| 312 |
+
or payload["url"] in assigned_urls
|
| 313 |
+
or str(item.get("assigned_uid") or "").strip() == str(uid or "").strip()
|
| 314 |
+
or str(item.get("assigned_license_key") or item.get("assigned_license") or "").strip() == str(license_key or "").strip()
|
| 315 |
+
)
|
| 316 |
+
if is_dedicated:
|
| 317 |
+
dedicated.append(payload)
|
| 318 |
+
elif not item.get("assigned_uid") and not item.get("assigned_license_key") and not item.get("assigned_license"):
|
| 319 |
+
auto_pool.append(payload)
|
| 320 |
+
except Exception:
|
| 321 |
+
return []
|
| 322 |
+
|
| 323 |
+
rows = dedicated or auto_pool
|
| 324 |
+
rows.sort(key=lambda item: (item["priority"], item["name"].lower()))
|
| 325 |
+
return rows
|
| 326 |
|
| 327 |
|
| 328 |
def verify_token(request: Request):
|
|
|
|
| 334 |
raise HTTPException(status_code=403, detail="Unauthorized")
|
| 335 |
|
| 336 |
|
|
|
|
|
|
|
| 337 |
_LICENSE_RATE_STATE = {}
|
| 338 |
_LICENSE_RATE_LOCK = threading.Lock()
|
| 339 |
|
|
|
|
| 340 |
def _bounded_env_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
| 341 |
try:
|
| 342 |
return max(minimum, min(maximum, int(os.environ.get(name, default))))
|
| 343 |
except (TypeError, ValueError):
|
| 344 |
return default
|
| 345 |
|
| 346 |
+
def _version_tuple(value: str) -> tuple[int, ...]:
|
| 347 |
+
parts = re.findall(r"\d+", str(value or ""))
|
| 348 |
+
numbers = [int(part) for part in parts[:4]]
|
| 349 |
+
return tuple((numbers + [0, 0, 0, 0])[:4])
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _reserve_monthly_usage(db, uid, license_key, lic, is_clone, requested_characters):
|
| 353 |
+
from datetime import datetime, timezone
|
| 354 |
+
|
| 355 |
+
requested_characters = max(0, int(requested_characters or 0))
|
| 356 |
+
if not requested_characters:
|
| 357 |
+
return
|
| 358 |
+
month_id = datetime.now(timezone.utc).strftime("%Y%m")
|
| 359 |
+
usage_ref = db.collection("usage_monthly").document(f"{uid}_{month_id}")
|
| 360 |
+
used_field = "clone_characters" if is_clone else "tts_characters"
|
| 361 |
+
reserved_field = "clone_reserved" if is_clone else "tts_reserved"
|
| 362 |
+
limit = int(
|
| 363 |
+
lic.get("monthly_clone_characters" if is_clone else "monthly_characters")
|
| 364 |
+
or 0
|
| 365 |
+
)
|
| 366 |
+
transaction = db.transaction()
|
| 367 |
+
|
| 368 |
+
@firestore.transactional
|
| 369 |
+
def reserve(transaction):
|
| 370 |
+
snapshot = usage_ref.get(transaction=transaction)
|
| 371 |
+
usage = snapshot.to_dict() if snapshot.exists else {}
|
| 372 |
+
used = max(0, int(usage.get(used_field) or 0))
|
| 373 |
+
reserved = max(0, int(usage.get(reserved_field) or 0))
|
| 374 |
+
if limit and used + reserved + requested_characters > limit:
|
| 375 |
+
label = "voice-clone" if is_clone else "TTS character"
|
| 376 |
+
raise HTTPException(
|
| 377 |
+
status_code=403,
|
| 378 |
+
detail=f"Monthly {label} allowance has been reached",
|
| 379 |
+
)
|
| 380 |
+
transaction.set(
|
| 381 |
+
usage_ref,
|
| 382 |
+
{
|
| 383 |
+
"uid": uid,
|
| 384 |
+
"license_key": license_key,
|
| 385 |
+
"period": month_id,
|
| 386 |
+
reserved_field: reserved + requested_characters,
|
| 387 |
+
"updated_at": firestore.SERVER_TIMESTAMP,
|
| 388 |
+
},
|
| 389 |
+
merge=True,
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
try:
|
| 393 |
+
reserve(transaction)
|
| 394 |
+
except HTTPException:
|
| 395 |
+
raise
|
| 396 |
+
except Exception as exc:
|
| 397 |
+
logging.warning("Usage reservation failed: %s", exc.__class__.__name__)
|
| 398 |
+
raise HTTPException(503, "Usage allowance could not be verified")
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def verify_firebase_auth(request, is_clone: bool = False, requested_characters: int = 0):
|
| 402 |
+
if not HAS_FIREBASE or not fb_auth:
|
| 403 |
+
raise HTTPException(503, "Firebase authentication is not configured")
|
| 404 |
+
auth_header = request.headers.get("Authorization", "")
|
| 405 |
+
if not auth_header.startswith("Bearer "):
|
| 406 |
+
raise HTTPException(401, "Missing authentication token")
|
| 407 |
+
token = auth_header.split("Bearer ", 1)[1]
|
| 408 |
try:
|
| 409 |
+
decoded = fb_auth.verify_id_token(token)
|
| 410 |
+
except Exception:
|
| 411 |
+
raise HTTPException(401, "Invalid or expired authentication token")
|
| 412 |
+
uid = decoded["uid"]
|
| 413 |
+
db = _init_firebase()
|
| 414 |
+
if db is None:
|
| 415 |
+
raise HTTPException(503, "Firebase database is unavailable")
|
| 416 |
+
user_doc = db.collection("users").document(uid).get()
|
| 417 |
+
if not user_doc.exists:
|
| 418 |
+
raise HTTPException(403, "User account not found")
|
| 419 |
+
user_data = user_doc.to_dict()
|
| 420 |
+
if user_data.get("is_blocked"):
|
| 421 |
+
raise HTTPException(403, "Account has been blocked")
|
| 422 |
+
license_key = user_data.get("license_key")
|
| 423 |
+
if not license_key:
|
| 424 |
+
raise HTTPException(403, "No active license. Please activate a license key.")
|
| 425 |
+
license_doc = db.collection("licenses").document(license_key).get()
|
| 426 |
+
if not license_doc.exists:
|
| 427 |
+
raise HTTPException(403, "License key not found")
|
| 428 |
+
lic = license_doc.to_dict()
|
| 429 |
+
if lic.get("status") not in ("active",):
|
| 430 |
+
raise HTTPException(403, f"License is {lic.get('status', 'invalid')}")
|
| 431 |
+
device_id = str(request.headers.get("X-Device-ID", "")).strip()[:128]
|
| 432 |
+
device_ids = list(user_data.get("device_ids") or [])
|
| 433 |
+
if not device_id or device_id not in device_ids:
|
| 434 |
+
raise HTTPException(403, "This device is not registered for the license")
|
| 435 |
+
if len(device_ids) > max(1, int(lic.get("max_devices") or 1)):
|
| 436 |
+
raise HTTPException(403, "Maximum licensed devices exceeded")
|
| 437 |
+
|
| 438 |
+
from datetime import datetime, timezone
|
| 439 |
+
if lic.get("expiry_date") and lic["expiry_date"].replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
|
| 440 |
+
raise HTTPException(403, "License has expired")
|
| 441 |
+
|
| 442 |
+
if is_clone and (
|
| 443 |
+
not lic.get("voice_clone")
|
| 444 |
+
or not user_data.get("voice_clone_enabled", lic.get("voice_clone", False))
|
| 445 |
+
):
|
| 446 |
+
raise HTTPException(403, "Voice cloning not included in your plan")
|
| 447 |
+
_reserve_monthly_usage(
|
| 448 |
+
db,
|
| 449 |
+
uid,
|
| 450 |
+
license_key,
|
| 451 |
+
lic,
|
| 452 |
+
is_clone,
|
| 453 |
+
requested_characters,
|
| 454 |
+
)
|
| 455 |
+
return uid, license_key, lic, user_data
|
| 456 |
+
|
| 457 |
+
def _enforce_rate_limit(uid: str, is_clone: bool):
|
| 458 |
now = time.monotonic()
|
| 459 |
window = 60.0
|
| 460 |
limit = _bounded_env_int(
|
|
|
|
| 463 |
1,
|
| 464 |
600,
|
| 465 |
)
|
| 466 |
+
state_key = f"{uid}:{'clone' if is_clone else 'tts'}"
|
| 467 |
with _LICENSE_RATE_LOCK:
|
| 468 |
requests_in_window = [
|
| 469 |
timestamp
|
|
|
|
| 475 |
requests_in_window.append(now)
|
| 476 |
_LICENSE_RATE_STATE[state_key] = requests_in_window
|
| 477 |
|
| 478 |
+
async def authorize_request(request: Request, engine: str, requested_characters: int = 0) -> dict:
|
| 479 |
+
policy = await asyncio.to_thread(get_runtime_policy)
|
| 480 |
+
if policy.get("maintenance_mode"):
|
| 481 |
+
raise HTTPException(status_code=503, detail="VoiceCraft is temporarily under maintenance")
|
| 482 |
+
if not policy.get("space_enabled", True):
|
| 483 |
+
raise HTTPException(status_code=503, detail="This worker has been disabled")
|
| 484 |
+
if engine in CLONE_ENGINES and not policy.get("space_clone_enabled", False):
|
| 485 |
+
raise HTTPException(status_code=403, detail="Voice cloning is not available on this server")
|
| 486 |
if AUTH_MODE in {"api_secret", "layered"}:
|
| 487 |
verify_token(request)
|
| 488 |
+
if AUTH_MODE == "api_secret":
|
| 489 |
+
return {"uid": "", "license_key": "", "license": {}}
|
| 490 |
+
|
| 491 |
+
client_version = request.headers.get("X-Client-Version", "").strip()
|
| 492 |
+
minimum_version = str(policy.get("minimum_version") or MIN_CLIENT_VERSION)
|
| 493 |
+
if not client_version:
|
| 494 |
+
raise HTTPException(status_code=426, detail="VoiceCraft client version is required")
|
| 495 |
+
if _version_tuple(client_version) < _version_tuple(minimum_version):
|
| 496 |
+
raise HTTPException(status_code=426, detail="VoiceCraft update required")
|
| 497 |
+
|
|
|
|
|
|
|
| 498 |
is_clone = engine in CLONE_ENGINES
|
| 499 |
+
uid, license_key, lic, _user = await asyncio.to_thread(
|
| 500 |
+
verify_firebase_auth,
|
| 501 |
+
request,
|
| 502 |
+
is_clone,
|
| 503 |
+
requested_characters,
|
| 504 |
+
)
|
| 505 |
+
_enforce_rate_limit(uid, is_clone)
|
| 506 |
+
return {"uid": uid, "license_key": license_key, "license": lic}
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def acquire_worker_slot():
|
| 510 |
+
global _ACTIVE_JOBS
|
| 511 |
+
if not _SPACE_CAPACITY.acquire(blocking=False):
|
| 512 |
+
raise HTTPException(status_code=503, detail="Worker is busy; try another Space")
|
| 513 |
+
with _SPACE_STATE_LOCK:
|
| 514 |
+
_ACTIVE_JOBS += 1
|
| 515 |
+
active = _ACTIVE_JOBS
|
| 516 |
+
db = _init_firebase()
|
| 517 |
+
if db is not None and SPACE_ID != "unregistered":
|
| 518 |
+
try:
|
| 519 |
+
db.collection("spaces").document(SPACE_ID).set({
|
| 520 |
+
"active_jobs": active,
|
| 521 |
+
"last_heartbeat": firestore.SERVER_TIMESTAMP,
|
| 522 |
+
}, merge=True)
|
| 523 |
+
except Exception:
|
| 524 |
+
pass
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def release_worker_slot():
|
| 528 |
+
global _ACTIVE_JOBS
|
| 529 |
+
with _SPACE_STATE_LOCK:
|
| 530 |
+
_ACTIVE_JOBS = max(0, _ACTIVE_JOBS - 1)
|
| 531 |
+
active = _ACTIVE_JOBS
|
| 532 |
+
_SPACE_CAPACITY.release()
|
| 533 |
+
db = _init_firebase()
|
| 534 |
+
if db is not None and SPACE_ID != "unregistered":
|
| 535 |
+
try:
|
| 536 |
+
db.collection("spaces").document(SPACE_ID).set({
|
| 537 |
+
"active_jobs": active,
|
| 538 |
+
"last_heartbeat": firestore.SERVER_TIMESTAMP,
|
| 539 |
+
}, merge=True)
|
| 540 |
+
except Exception:
|
| 541 |
+
pass
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def record_usage(auth_context, engine, characters, status, duration_ms):
|
| 545 |
+
uid = str((auth_context or {}).get("uid") or "")
|
| 546 |
+
if not uid:
|
| 547 |
+
return
|
| 548 |
+
db = _init_firebase()
|
| 549 |
+
if db is None:
|
| 550 |
+
return
|
| 551 |
+
is_clone = engine in CLONE_ENGINES
|
| 552 |
+
event = {
|
| 553 |
+
"uid": uid,
|
| 554 |
+
"license_key": str(auth_context.get("license_key") or ""),
|
| 555 |
+
"space_id": SPACE_ID,
|
| 556 |
+
"engine": engine,
|
| 557 |
+
"characters": int(characters),
|
| 558 |
+
"is_clone": is_clone,
|
| 559 |
+
"status": status,
|
| 560 |
+
"duration_ms": int(duration_ms),
|
| 561 |
+
"created_at": firestore.SERVER_TIMESTAMP,
|
| 562 |
+
}
|
| 563 |
+
try:
|
| 564 |
+
batch = db.batch()
|
| 565 |
+
event_ref = db.collection("usage_events").document()
|
| 566 |
+
user_ref = db.collection("users").document(uid)
|
| 567 |
+
space_ref = db.collection("spaces").document(SPACE_ID)
|
| 568 |
+
from datetime import datetime, timezone
|
| 569 |
+
month_id = datetime.now(timezone.utc).strftime("%Y%m")
|
| 570 |
+
monthly_ref = db.collection("usage_monthly").document(f"{uid}_{month_id}")
|
| 571 |
+
batch.set(event_ref, event)
|
| 572 |
+
billed_characters = int(characters) if status == "success" else 0
|
| 573 |
+
reserved_field = "clone_reserved" if is_clone else "tts_reserved"
|
| 574 |
+
user_updates = {
|
| 575 |
+
"generation_count": firestore.Increment(1),
|
| 576 |
+
"last_generation_at": firestore.SERVER_TIMESTAMP,
|
| 577 |
+
"tts_characters": firestore.Increment(0 if is_clone else billed_characters),
|
| 578 |
+
"clone_characters": firestore.Increment(billed_characters if is_clone else 0),
|
| 579 |
+
}
|
| 580 |
+
batch.set(user_ref, user_updates, merge=True)
|
| 581 |
+
batch.set(monthly_ref, {
|
| 582 |
+
"uid": uid,
|
| 583 |
+
"period": month_id,
|
| 584 |
+
"tts_characters": firestore.Increment(0 if is_clone else billed_characters),
|
| 585 |
+
"clone_characters": firestore.Increment(billed_characters if is_clone else 0),
|
| 586 |
+
reserved_field: firestore.Increment(-int(characters)),
|
| 587 |
+
"generation_count": firestore.Increment(1),
|
| 588 |
+
"updated_at": firestore.SERVER_TIMESTAMP,
|
| 589 |
+
}, merge=True)
|
| 590 |
+
if SPACE_ID != "unregistered":
|
| 591 |
+
batch.set(space_ref, {
|
| 592 |
+
"total_requests": firestore.Increment(1),
|
| 593 |
+
"total_characters": firestore.Increment(billed_characters),
|
| 594 |
+
"last_heartbeat": firestore.SERVER_TIMESTAMP,
|
| 595 |
+
}, merge=True)
|
| 596 |
+
batch.commit()
|
| 597 |
+
except Exception as exc:
|
| 598 |
+
logging.warning("Usage logging failed: %s", exc.__class__.__name__)
|
| 599 |
+
|
| 600 |
+
|
| 601 |
|
| 602 |
# â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
| 603 |
# PIPER TTS SETUP — Auto download on first run
|
|
|
|
| 703 |
return onnx_path, json_path
|
| 704 |
|
| 705 |
|
| 706 |
+
def _piper_synth_chunk(text: str, onnx_path: str, json_path: str, length_scale: float) -> bytes:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 707 |
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as out_f:
|
| 708 |
out_path = out_f.name
|
| 709 |
try:
|
|
|
|
| 730 |
os.unlink(out_path)
|
| 731 |
|
| 732 |
|
| 733 |
+
def _numpy_concat(parts: list, is_mp3: bool) -> bytes:
|
|
|
|
| 734 |
if len(parts) == 1:
|
| 735 |
return parts[0]
|
| 736 |
+
import numpy as np
|
| 737 |
+
import soundfile as sf
|
| 738 |
+
import subprocess, tempfile, os
|
| 739 |
+
|
| 740 |
+
audio_arrays = []
|
| 741 |
+
samplerate = 24000
|
| 742 |
+
|
| 743 |
+
for data in parts:
|
| 744 |
+
fd, in_path = tempfile.mkstemp(suffix=".mp3" if is_mp3 else ".wav")
|
| 745 |
os.close(fd)
|
| 746 |
+
with open(in_path, "wb") as f:
|
| 747 |
+
f.write(data)
|
| 748 |
+
|
| 749 |
+
wav_path = in_path
|
| 750 |
+
if is_mp3:
|
| 751 |
+
fd, wav_path = tempfile.mkstemp(suffix=".wav")
|
| 752 |
+
os.close(fd)
|
| 753 |
+
subprocess.run(["ffmpeg", "-y", "-i", in_path, wav_path], check=True, capture_output=True)
|
| 754 |
+
|
| 755 |
+
try:
|
| 756 |
+
arr, sr = sf.read(wav_path)
|
| 757 |
+
audio_arrays.append(arr)
|
| 758 |
+
samplerate = sr
|
| 759 |
+
except Exception:
|
| 760 |
+
pass
|
| 761 |
+
|
| 762 |
+
try: os.unlink(in_path)
|
| 763 |
+
except: pass
|
| 764 |
+
if is_mp3:
|
| 765 |
+
try: os.unlink(wav_path)
|
| 766 |
+
except: pass
|
| 767 |
+
|
| 768 |
+
if not audio_arrays:
|
| 769 |
+
return b""
|
| 770 |
+
|
| 771 |
+
result = audio_arrays[0]
|
| 772 |
+
fade_len = int(samplerate * 0.075)
|
| 773 |
+
silence_len = int(samplerate * 0.15)
|
| 774 |
+
silence = np.zeros(silence_len, dtype=result.dtype)
|
| 775 |
+
|
| 776 |
+
for next_arr in audio_arrays[1:]:
|
| 777 |
+
result = np.concatenate([result, silence])
|
| 778 |
+
if len(result) > fade_len and len(next_arr) > fade_len:
|
| 779 |
+
fade_out = np.linspace(1.0, 0.0, fade_len)
|
| 780 |
+
fade_in = np.linspace(0.0, 1.0, fade_len)
|
| 781 |
+
|
| 782 |
+
if len(result.shape) > 1:
|
| 783 |
+
fade_out = fade_out[:, np.newaxis]
|
| 784 |
+
fade_in = fade_in[:, np.newaxis]
|
| 785 |
+
|
| 786 |
+
overlap_result = result[-fade_len:] * fade_out
|
| 787 |
+
overlap_next = next_arr[:fade_len] * fade_in
|
| 788 |
+
|
| 789 |
+
result[-fade_len:] = overlap_result + overlap_next
|
| 790 |
+
result = np.concatenate([result, next_arr[fade_len:]])
|
| 791 |
+
else:
|
| 792 |
+
result = np.concatenate([result, next_arr])
|
| 793 |
+
|
| 794 |
+
fd, out_wav = tempfile.mkstemp(suffix=".wav")
|
| 795 |
+
os.close(fd)
|
| 796 |
+
sf.write(out_wav, result, samplerate)
|
| 797 |
+
|
| 798 |
+
if is_mp3:
|
| 799 |
+
fd, out_mp3 = tempfile.mkstemp(suffix=".mp3")
|
| 800 |
os.close(fd)
|
| 801 |
+
subprocess.run(["ffmpeg", "-y", "-i", out_wav, out_mp3], check=True, capture_output=True)
|
| 802 |
+
with open(out_mp3, "rb") as f:
|
| 803 |
+
res = f.read()
|
| 804 |
+
os.unlink(out_mp3)
|
| 805 |
+
os.unlink(out_wav)
|
| 806 |
+
return res
|
| 807 |
+
else:
|
| 808 |
+
with open(out_wav, "rb") as f:
|
| 809 |
+
res = f.read()
|
| 810 |
+
os.unlink(out_wav)
|
| 811 |
+
return res
|
| 812 |
+
|
| 813 |
+
def _ffmpeg_concat_wav(parts: list) -> bytes:
|
| 814 |
+
return _numpy_concat(parts, False)
|
|
|
|
| 815 |
|
| 816 |
|
| 817 |
def synthesize_piper(text: str, voice_code: str, speed: float = 1.0) -> bytes:
|
| 818 |
if not PIPER_READY:
|
| 819 |
raise Exception("Piper is not available on this system")
|
| 820 |
+
# Only explicitly tested models are permitted.
|
| 821 |
+
if voice_code not in PIPER_VOICES:
|
| 822 |
+
raise ValueError("Unsupported Piper voice")
|
| 823 |
+
onnx_path, json_path = download_piper_model(voice_code)
|
|
|
|
|
|
|
| 824 |
length_scale = 1.0 / max(0.25, min(4.0, speed))
|
| 825 |
# Lambi text ko chunk karo (Piper stdin limit + timeout avoid karne ke liye)
|
| 826 |
if len(text) > 1400:
|
|
|
|
| 838 |
return _ffmpeg_concat_wav(parts)
|
| 839 |
|
| 840 |
|
| 841 |
+
def sanitize_text(text: str) -> str:
|
| 842 |
+
import re
|
| 843 |
+
text = re.sub(r'[\u200B-\u200D\uFEFF]', '', text)
|
| 844 |
+
text = re.sub(r'[\x00-\x08\x0b-\x1f\x7f]', '', text)
|
| 845 |
+
text = re.sub(r'[ \t]+', ' ', text)
|
| 846 |
+
text = re.sub(r'\n\s*\n+', '\n\n', text)
|
| 847 |
+
return text.strip()
|
| 848 |
+
|
| 849 |
def split_text(text: str, max_chars: int = 1400) -> list:
|
| 850 |
"""Text ko chunklara bol"""
|
| 851 |
+
text = sanitize_text(text)
|
| 852 |
if not text:
|
| 853 |
return []
|
| 854 |
sentence_re = re.compile(
|
| 855 |
+
r'(?<=[.!?\u0964\u06D4\u061F\u2026\u3002\uff01\uff1f])\s+(?=\S)'
|
| 856 |
)
|
| 857 |
chunks, current = [], ""
|
| 858 |
for para in re.split(r'\n+', text):
|
|
|
|
| 995 |
|
| 996 |
|
| 997 |
def _ffmpeg_concat_mp3(parts: list) -> bytes:
|
| 998 |
+
return _numpy_concat(parts, True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 999 |
|
| 1000 |
|
| 1001 |
async def _edge_synth_chunk(chunk: str, voice: str, kwargs: dict) -> bytes:
|
| 1002 |
"""One chunk ki audio lao, 3 baar retry karo. Fail par b"" return."""
|
| 1003 |
import edge_tts
|
| 1004 |
+
import asyncio
|
| 1005 |
for attempt in range(3):
|
| 1006 |
data = bytearray()
|
| 1007 |
try:
|
|
|
|
| 1013 |
return bytes(data)
|
| 1014 |
except Exception:
|
| 1015 |
if attempt == 2:
|
| 1016 |
+
if voice != "en-US-AriaNeural":
|
| 1017 |
+
try:
|
| 1018 |
+
comm = edge_tts.Communicate(chunk, "en-US-AriaNeural", **kwargs)
|
| 1019 |
+
async for packet in comm.stream():
|
| 1020 |
+
if packet["type"] == "audio" and packet.get("data"):
|
| 1021 |
+
data.extend(packet["data"])
|
| 1022 |
+
if data:
|
| 1023 |
+
return bytes(data)
|
| 1024 |
+
except Exception:
|
| 1025 |
+
pass
|
| 1026 |
return b""
|
| 1027 |
+
await asyncio.sleep(2)
|
| 1028 |
return b""
|
| 1029 |
|
| 1030 |
|
|
|
|
| 1069 |
"f5tts:v1_base": ("f5tts", "multilingual", "Voice Clone"),
|
| 1070 |
}
|
| 1071 |
|
| 1072 |
+
_POCKET_MODEL = None
|
| 1073 |
+
_POCKET_MODEL_ERROR = None
|
| 1074 |
+
_POCKET_GENERATION_ERROR = None
|
| 1075 |
+
_POCKET_MODEL_LOCK = threading.Lock()
|
| 1076 |
_POCKET_INFER_LOCK = threading.Lock()
|
| 1077 |
_POCKET_RESULT_CACHE = {}
|
| 1078 |
_POCKET_RESULT_CACHE_ORDER = []
|
|
|
|
| 1081 |
_POCKET_RESULT_CACHE_MAX_BYTES = 16 * 1024 * 1024
|
| 1082 |
_POCKET_VOICE_STATE_CACHE = {}
|
| 1083 |
_POCKET_VOICE_STATE_ORDER = []
|
| 1084 |
+
_POCKET_VOICE_STATE_LOCK = threading.Lock()
|
| 1085 |
+
_POCKET_VOICE_STATE_LIMIT = 6
|
| 1086 |
+
_POCKET_TORCH_PATCHED = False
|
| 1087 |
|
| 1088 |
|
| 1089 |
def _hf_token_configured() -> bool:
|
|
|
|
| 1101 |
)
|
| 1102 |
|
| 1103 |
|
| 1104 |
+
def _pocket_clone_ready() -> bool:
|
| 1105 |
+
return bool(_POCKET_MODEL is not None and getattr(_POCKET_MODEL, "has_voice_cloning", False))
|
| 1106 |
+
|
| 1107 |
+
|
| 1108 |
+
def _patch_torch_for_pocket_tts():
|
| 1109 |
+
"""Pocket TTS mutates a streaming KV cache; no_grad keeps tensors mutable."""
|
| 1110 |
+
global _POCKET_TORCH_PATCHED
|
| 1111 |
+
if _POCKET_TORCH_PATCHED:
|
| 1112 |
+
return
|
| 1113 |
+
try:
|
| 1114 |
+
import torch
|
| 1115 |
+
torch.inference_mode = torch.no_grad
|
| 1116 |
+
_POCKET_TORCH_PATCHED = True
|
| 1117 |
+
logging.info("Pocket TTS torch compatibility patch enabled")
|
| 1118 |
+
except Exception as exc:
|
| 1119 |
+
logging.warning("Pocket TTS torch patch skipped: %s", exc.__class__.__name__)
|
| 1120 |
+
|
| 1121 |
+
|
| 1122 |
+
def _load_pocket_model():
|
| 1123 |
+
global _POCKET_MODEL, _POCKET_MODEL_ERROR
|
| 1124 |
+
if _POCKET_MODEL is not None:
|
| 1125 |
+
return _POCKET_MODEL
|
| 1126 |
+
with _POCKET_MODEL_LOCK:
|
| 1127 |
+
if _POCKET_MODEL is not None:
|
| 1128 |
+
return _POCKET_MODEL
|
| 1129 |
+
try:
|
| 1130 |
+
_patch_torch_for_pocket_tts()
|
| 1131 |
+
from pocket_tts import TTSModel
|
| 1132 |
+
_POCKET_MODEL = TTSModel.load_model()
|
| 1133 |
+
# Pocket TTS is inference-only in this service. Disabling gradient
|
| 1134 |
+
# tracking reduces CPU work and memory without changing synthesis.
|
| 1135 |
+
try:
|
| 1136 |
+
import torch
|
| 1137 |
+
torch.set_grad_enabled(False)
|
| 1138 |
+
except Exception:
|
| 1139 |
+
pass
|
| 1140 |
_POCKET_MODEL_ERROR = None
|
| 1141 |
if getattr(_POCKET_MODEL, "has_voice_cloning", False):
|
| 1142 |
print("Pocket TTS CPU voice clone ready")
|
|
|
|
| 1214 |
"Reference audio could not be decoded. "
|
| 1215 |
f"Use a clear WAV, MP3, M4A, OGG, or FLAC file. {detail[:160]}"
|
| 1216 |
)
|
| 1217 |
+
|
| 1218 |
+
try:
|
| 1219 |
+
import soundfile as sf
|
| 1220 |
+
import numpy as np
|
| 1221 |
+
data, sr = sf.read(normalized_path)
|
| 1222 |
+
if len(data.shape) > 1:
|
| 1223 |
+
data = data.mean(axis=1)
|
| 1224 |
+
|
| 1225 |
+
sr = sr or 24000
|
| 1226 |
+
duration = len(data) / float(sr)
|
| 1227 |
+
|
| 1228 |
+
# Auto-trim if longer than 12s, auto-tile if shorter than 3s
|
| 1229 |
+
if duration > 12.0:
|
| 1230 |
+
data = data[:int(12.0 * sr)]
|
| 1231 |
+
elif duration < 3.0 and duration > 0.3:
|
| 1232 |
+
repeats = int(np.ceil(3.0 / duration))
|
| 1233 |
+
data = np.tile(data, repeats)[:int(3.0 * sr)]
|
| 1234 |
+
|
| 1235 |
+
rms = np.sqrt(np.mean(data**2))
|
| 1236 |
+
if rms < 0.0005:
|
| 1237 |
+
raise ValueError("Reference audio is mostly silence. Please record a clearer voice sample.")
|
| 1238 |
+
|
| 1239 |
+
target_rms = 10 ** (-20 / 20)
|
| 1240 |
+
data = data * (target_rms / (rms + 1e-9))
|
| 1241 |
+
|
| 1242 |
+
threshold = 10 ** (-40 / 20)
|
| 1243 |
+
mask = np.abs(data) > threshold
|
| 1244 |
+
if np.any(mask):
|
| 1245 |
+
start = np.argmax(mask)
|
| 1246 |
+
end = len(mask) - np.argmax(mask[::-1])
|
| 1247 |
+
data = data[start:end]
|
| 1248 |
+
|
| 1249 |
+
sf.write(normalized_path, data, sr)
|
| 1250 |
+
except ValueError:
|
| 1251 |
+
raise
|
| 1252 |
+
except Exception:
|
| 1253 |
+
pass
|
| 1254 |
return normalized_path
|
| 1255 |
except Exception:
|
| 1256 |
try:
|
|
|
|
| 1267 |
|
| 1268 |
def _get_cached_clone(cache_key: str):
|
| 1269 |
with _POCKET_RESULT_CACHE_LOCK:
|
| 1270 |
+
audio = _POCKET_RESULT_CACHE.get(cache_key)
|
| 1271 |
+
if audio is not None:
|
| 1272 |
+
try:
|
| 1273 |
+
_POCKET_RESULT_CACHE_ORDER.remove(cache_key)
|
| 1274 |
+
except ValueError:
|
| 1275 |
+
pass
|
| 1276 |
+
_POCKET_RESULT_CACHE_ORDER.append(cache_key)
|
| 1277 |
+
return audio
|
| 1278 |
|
| 1279 |
|
| 1280 |
def _cache_clone(cache_key: str, audio: bytes):
|
|
|
|
| 1292 |
|
| 1293 |
def _get_cached_voice_state(reference_key: str):
|
| 1294 |
with _POCKET_VOICE_STATE_LOCK:
|
| 1295 |
+
voice_state = _POCKET_VOICE_STATE_CACHE.get(reference_key)
|
| 1296 |
+
if voice_state is not None:
|
| 1297 |
+
try:
|
| 1298 |
+
_POCKET_VOICE_STATE_ORDER.remove(reference_key)
|
| 1299 |
+
except ValueError:
|
| 1300 |
+
pass
|
| 1301 |
+
_POCKET_VOICE_STATE_ORDER.append(reference_key)
|
| 1302 |
+
return voice_state
|
| 1303 |
|
| 1304 |
|
| 1305 |
+
def _cache_voice_state(reference_key: str, voice_state):
|
| 1306 |
with _POCKET_VOICE_STATE_LOCK:
|
| 1307 |
if reference_key in _POCKET_VOICE_STATE_CACHE:
|
| 1308 |
_POCKET_VOICE_STATE_ORDER.remove(reference_key)
|
|
|
|
| 1310 |
_POCKET_VOICE_STATE_ORDER.append(reference_key)
|
| 1311 |
while len(_POCKET_VOICE_STATE_ORDER) > _POCKET_VOICE_STATE_LIMIT:
|
| 1312 |
oldest = _POCKET_VOICE_STATE_ORDER.pop(0)
|
| 1313 |
+
_POCKET_VOICE_STATE_CACHE.pop(oldest, None)
|
| 1314 |
+
|
| 1315 |
+
|
| 1316 |
+
def _estimate_min_clone_seconds(text: str) -> float:
|
| 1317 |
+
words = len(re.findall(r"\S+", str(text or "")))
|
| 1318 |
+
# A very conservative floor; natural speech can be fast, but a much shorter
|
| 1319 |
+
# result usually means the model stopped early and skipped words.
|
| 1320 |
+
return max(0.45, min(20.0, words * 0.105))
|
| 1321 |
+
|
| 1322 |
+
|
| 1323 |
+
def _generate_pocket_chunk(model, voice_state, chunk: str):
|
| 1324 |
+
import numpy as np
|
| 1325 |
+
|
| 1326 |
+
audio = model.generate_audio(voice_state, chunk)
|
| 1327 |
+
if hasattr(audio, "detach"):
|
| 1328 |
+
audio = audio.detach().cpu().numpy()
|
| 1329 |
+
return np.asarray(audio).squeeze().reshape(-1)
|
| 1330 |
+
|
| 1331 |
+
|
| 1332 |
+
@app.api(name="voicecraft_clone_backend", api_visibility="private", concurrency_limit=1)
|
| 1333 |
+
def _run_pocket_clone_cpu(text: str, reference_path: str, reference_key: str) -> bytes:
|
| 1334 |
if not reference_path or not os.path.exists(reference_path):
|
| 1335 |
raise ValueError("A reference audio file is required for voice cloning")
|
| 1336 |
model = _load_pocket_model()
|
|
|
|
| 1351 |
raise
|
| 1352 |
_cache_voice_state(reference_key, voice_state)
|
| 1353 |
chunks = split_text(text, max_chars=CLONE_CHUNK_CHARACTERS)
|
| 1354 |
+
try:
|
| 1355 |
+
import torch
|
| 1356 |
+
inference_context = torch.no_grad()
|
| 1357 |
+
except Exception:
|
| 1358 |
+
inference_context = nullcontext()
|
| 1359 |
+
with inference_context:
|
| 1360 |
+
for chunk in chunks:
|
| 1361 |
+
audio_np = _generate_pocket_chunk(model, voice_state, chunk)
|
| 1362 |
+
duration = audio_np.size / float(getattr(model, "sample_rate", 24000) or 24000)
|
| 1363 |
+
if (
|
| 1364 |
+
audio_np.size
|
| 1365 |
+
and len(chunk) > CLONE_RETRY_CHUNK_CHARACTERS
|
| 1366 |
+
and duration < _estimate_min_clone_seconds(chunk)
|
| 1367 |
+
):
|
| 1368 |
+
logging.warning("Clone chunk looked truncated; retrying with smaller chunks")
|
| 1369 |
+
smaller_parts = []
|
| 1370 |
+
for sub_chunk in split_text(chunk, max_chars=CLONE_RETRY_CHUNK_CHARACTERS):
|
| 1371 |
+
sub_audio = _generate_pocket_chunk(model, voice_state, sub_chunk)
|
| 1372 |
+
if sub_audio.size:
|
| 1373 |
+
smaller_parts.append(sub_audio)
|
| 1374 |
+
if smaller_parts:
|
| 1375 |
+
silence = np.zeros(int(model.sample_rate * 0.08), dtype=smaller_parts[0].dtype)
|
| 1376 |
+
joined = []
|
| 1377 |
+
for index, part in enumerate(smaller_parts):
|
| 1378 |
+
if index:
|
| 1379 |
+
joined.append(silence)
|
| 1380 |
+
joined.append(part)
|
| 1381 |
+
audio_np = np.concatenate(joined)
|
| 1382 |
+
if audio_np.size:
|
| 1383 |
+
audio_parts.append(audio_np)
|
| 1384 |
if not audio_parts:
|
| 1385 |
raise RuntimeError("Voice clone model did not produce audio")
|
| 1386 |
if len(audio_parts) == 1:
|
|
|
|
| 1401 |
return buf.getvalue()
|
| 1402 |
|
| 1403 |
|
| 1404 |
+
async def synthesize_f5tts(text: str, reference_path: str = None) -> bytes:
|
| 1405 |
+
global _POCKET_GENERATION_ERROR
|
| 1406 |
+
if not reference_path or not os.path.exists(reference_path):
|
| 1407 |
+
raise ValueError("A reference audio file is required for voice cloning")
|
| 1408 |
+
try:
|
| 1409 |
+
reference_key = await asyncio.to_thread(_reference_audio_key, reference_path)
|
| 1410 |
+
cache_key = _clone_request_key(text, reference_key)
|
| 1411 |
+
cached = _get_cached_clone(cache_key)
|
| 1412 |
+
if cached is not None:
|
| 1413 |
+
return cached
|
| 1414 |
+
if _get_cached_voice_state(reference_key) is not None:
|
| 1415 |
+
# Same reference voice is already encoded. Skip ffmpeg normalization for repeated
|
| 1416 |
+
# desktop batches/generations; audio quality is unchanged because voice_state is reused.
|
| 1417 |
+
audio = await asyncio.to_thread(
|
| 1418 |
+
_run_pocket_clone_cpu, text, reference_path, reference_key
|
| 1419 |
+
)
|
| 1420 |
+
else:
|
| 1421 |
+
normalized_path = await asyncio.to_thread(_normalize_clone_reference, reference_path)
|
| 1422 |
+
try:
|
| 1423 |
+
audio = await asyncio.to_thread(
|
| 1424 |
+
_run_pocket_clone_cpu, text, normalized_path, reference_key
|
| 1425 |
+
)
|
| 1426 |
+
finally:
|
| 1427 |
+
try:
|
| 1428 |
+
os.unlink(normalized_path)
|
| 1429 |
+
except OSError:
|
| 1430 |
+
pass
|
| 1431 |
+
_cache_clone(cache_key, audio)
|
| 1432 |
+
_POCKET_GENERATION_ERROR = None
|
| 1433 |
+
return audio
|
| 1434 |
+
except Exception as exc:
|
| 1435 |
+
_POCKET_GENERATION_ERROR = exc.__class__.__name__
|
| 1436 |
+
logging.exception("Voice clone generation failed")
|
| 1437 |
+
raise RuntimeError("Voice cloning failed on this server. Please contact support.") from exc
|
| 1438 |
+
|
| 1439 |
+
def post_process_bytes(audio: bytes, media_type: str) -> bytes:
|
| 1440 |
+
"""Apply gentle, smooth speech cleanup without block-gate clicks.
|
| 1441 |
+
|
| 1442 |
+
A single ffmpeg pass is faster than decoding into Python/SciPy, walking
|
| 1443 |
+
every 20 ms window, writing a WAV, and encoding again. The conservative
|
| 1444 |
+
denoise amount removes low-level hiss while preserving voice character.
|
| 1445 |
+
"""
|
| 1446 |
+
suffix = ".mp3" if "mpeg" in media_type else ".wav"
|
| 1447 |
+
fd, in_path = tempfile.mkstemp(suffix=suffix)
|
| 1448 |
+
os.close(fd)
|
| 1449 |
+
with open(in_path, "wb") as f:
|
| 1450 |
+
f.write(audio)
|
| 1451 |
+
fd, out_path = tempfile.mkstemp(suffix=suffix)
|
| 1452 |
+
os.close(fd)
|
| 1453 |
+
try:
|
| 1454 |
+
ffmpeg = shutil.which("ffmpeg")
|
| 1455 |
+
if not ffmpeg:
|
| 1456 |
+
return audio
|
| 1457 |
+
filters = (
|
| 1458 |
+
"highpass=f=55,"
|
| 1459 |
+
"afftdn=nr=4:nf=-55:tn=1,"
|
| 1460 |
+
"alimiter=limit=0.891:attack=5:release=50"
|
| 1461 |
)
|
| 1462 |
+
cmd = [
|
| 1463 |
+
ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
|
| 1464 |
+
"-i", in_path, "-vn", "-af", filters,
|
| 1465 |
+
]
|
| 1466 |
+
if suffix == ".mp3":
|
| 1467 |
+
# Explicit high-quality encoding avoids ffmpeg's lower default
|
| 1468 |
+
# bitrate and prevents an avoidable quality drop.
|
| 1469 |
+
cmd += ["-codec:a", "libmp3lame", "-b:a", "320k", out_path]
|
| 1470 |
+
else:
|
| 1471 |
+
cmd += ["-acodec", "pcm_s16le", out_path]
|
| 1472 |
+
result = subprocess.run(
|
| 1473 |
+
cmd,
|
| 1474 |
+
capture_output=True,
|
| 1475 |
+
timeout=300,
|
| 1476 |
+
creationflags=CREATE_NO_WINDOW,
|
| 1477 |
+
)
|
| 1478 |
+
if result.returncode != 0 or not os.path.exists(out_path) or os.path.getsize(out_path) <= 44:
|
| 1479 |
+
logging.warning("Audio cleanup failed with ffmpeg exit code %s", result.returncode)
|
| 1480 |
+
return audio
|
| 1481 |
+
with open(out_path, "rb") as output_file:
|
| 1482 |
+
return output_file.read()
|
| 1483 |
+
except Exception as exc:
|
| 1484 |
+
logging.warning("Audio cleanup failed: %s", exc.__class__.__name__)
|
| 1485 |
+
return audio
|
| 1486 |
+
finally:
|
| 1487 |
+
for p in (in_path, out_path):
|
| 1488 |
+
if os.path.exists(p):
|
| 1489 |
+
try:
|
| 1490 |
+
os.unlink(p)
|
| 1491 |
+
except OSError:
|
| 1492 |
+
pass
|
|
|
|
|
|
|
| 1493 |
|
| 1494 |
@app.post("/tts")
|
| 1495 |
async def tts_endpoint(
|
| 1496 |
request: Request,
|
| 1497 |
+
engine: str = Form(...), # "edge" | "piper" | "silero" | "f5tts"
|
| 1498 |
text: str = Form(...),
|
| 1499 |
voice: str = Form("en-US-AvaNeural"), # edge voice code OR piper/silero code
|
| 1500 |
rate: str = Form("+0%"), # edge only
|
|
|
|
| 1503 |
speed: float = Form(1.0), # piper only
|
| 1504 |
style: str = Form(None), # edge style (emotion)
|
| 1505 |
styledegree: str = Form(None), # edge style degree 0-2
|
| 1506 |
+
post_process: bool = Form(True), # audio post-processing toggle
|
| 1507 |
voice_cloning: bool = Form(False), # voice cloning toggle
|
| 1508 |
reference_audio: UploadFile = File(None),
|
| 1509 |
):
|
| 1510 |
if not text or not text.strip():
|
| 1511 |
return JSONResponse(status_code=400, content={"error": "Text is empty"})
|
| 1512 |
|
| 1513 |
+
text = text.strip()
|
| 1514 |
|
| 1515 |
# Abuse / timeout guard — lambi text Chapter Mode se bhejo
|
| 1516 |
if len(text) > 60000:
|
|
|
|
| 1519 |
content={"error": "Text too long (max 60000 chars). Use Chapter Mode for longer text."},
|
| 1520 |
)
|
| 1521 |
|
| 1522 |
+
engine = engine.strip().lower()
|
| 1523 |
+
if engine not in available_engines():
|
| 1524 |
+
return JSONResponse(status_code=400, content={"error": "Unsupported TTS engine"})
|
| 1525 |
+
|
| 1526 |
+
has_reference = bool(reference_audio is not None and reference_audio.filename)
|
| 1527 |
+
if has_reference and engine not in CLONE_ENGINES:
|
| 1528 |
+
return JSONResponse(
|
| 1529 |
+
status_code=400,
|
| 1530 |
+
content={"error": "Reference audio is only accepted for voice cloning"},
|
| 1531 |
+
)
|
| 1532 |
+
if engine in CLONE_ENGINES and not has_reference:
|
| 1533 |
+
return JSONResponse(
|
| 1534 |
+
status_code=400,
|
| 1535 |
+
content={"error": "Voice cloning requires reference audio"},
|
| 1536 |
+
)
|
| 1537 |
+
if engine == "piper" and voice not in PIPER_VOICES:
|
| 1538 |
+
return JSONResponse(status_code=400, content={"error": "Unsupported Piper voice"})
|
| 1539 |
+
if engine == "silero" and not str(voice).startswith("silero:ru_"):
|
| 1540 |
+
return JSONResponse(status_code=400, content={"error": "Unsupported Silero voice"})
|
| 1541 |
+
if engine == "edge" and not re.match(r"^[a-z]{2,3}-[A-Z]{2}-.+Neural$", str(voice)):
|
| 1542 |
+
return JSONResponse(status_code=400, content={"error": "Unsupported Edge voice"})
|
| 1543 |
+
|
| 1544 |
+
auth_context = await authorize_request(request, engine, len(text))
|
| 1545 |
+
|
| 1546 |
+
if engine in CLONE_ENGINES and not clone_backend_available():
|
| 1547 |
+
return JSONResponse(
|
| 1548 |
+
status_code=403,
|
| 1549 |
+
content={"error": "Voice cloning is not available on this server"},
|
| 1550 |
+
)
|
| 1551 |
if engine in CLONE_ENGINES and len(text) > MAX_CLONE_CHARACTERS:
|
| 1552 |
return JSONResponse(
|
| 1553 |
status_code=413,
|
|
|
|
| 1559 |
},
|
| 1560 |
)
|
| 1561 |
|
| 1562 |
+
reference_path = None
|
| 1563 |
+
started_at = time.monotonic()
|
| 1564 |
+
generation_status = "error"
|
| 1565 |
+
slot_acquired = False
|
| 1566 |
+
try:
|
| 1567 |
+
acquire_worker_slot()
|
| 1568 |
+
slot_acquired = True
|
| 1569 |
+
if has_reference:
|
| 1570 |
suffix = os.path.splitext(reference_audio.filename)[1].lower() or ".wav"
|
| 1571 |
if suffix not in (".wav", ".mp3", ".m4a", ".ogg", ".flac"):
|
| 1572 |
return JSONResponse(status_code=400, content={"error": "Unsupported reference audio format"})
|
|
|
|
| 1603 |
media = "audio/wav"
|
| 1604 |
fname = "tts_f5_clone.wav"
|
| 1605 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1606 |
else:
|
| 1607 |
return JSONResponse(status_code=400, content={"error": f"Unknown engine: {engine}"})
|
| 1608 |
|
| 1609 |
+
# Preserve clone identity and improve speed: Pocket TTS already emits
|
| 1610 |
+
# clean PCM. Re-filtering cloned audio can subtly change timbre and
|
| 1611 |
+
# adds a full ffmpeg pass. Normal TTS still receives gentle cleanup.
|
| 1612 |
+
if post_process and engine not in CLONE_ENGINES:
|
| 1613 |
+
audio = await asyncio.to_thread(post_process_bytes, audio, media)
|
| 1614 |
+
|
| 1615 |
+
generation_status = "success"
|
| 1616 |
+
return StreamingResponse(
|
| 1617 |
io.BytesIO(audio),
|
| 1618 |
media_type=media,
|
| 1619 |
headers={"Content-Disposition": f"attachment; filename={fname}"},
|
|
|
|
| 1623 |
logging.error(f"TTS error: {e}", exc_info=True)
|
| 1624 |
return JSONResponse(
|
| 1625 |
status_code=500,
|
| 1626 |
+
content={"error": "Audio generation failed. Please retry shortly."},
|
| 1627 |
)
|
| 1628 |
+
finally:
|
| 1629 |
+
await asyncio.to_thread(
|
| 1630 |
+
record_usage,
|
| 1631 |
+
auth_context,
|
| 1632 |
+
engine,
|
| 1633 |
+
len(text),
|
| 1634 |
+
generation_status,
|
| 1635 |
+
int((time.monotonic() - started_at) * 1000),
|
| 1636 |
+
)
|
| 1637 |
+
if slot_acquired:
|
| 1638 |
+
release_worker_slot()
|
| 1639 |
+
try:
|
| 1640 |
if reference_path and os.path.exists(reference_path):
|
| 1641 |
os.unlink(reference_path)
|
| 1642 |
except Exception:
|
| 1643 |
pass
|
| 1644 |
|
| 1645 |
|
| 1646 |
+
|
| 1647 |
+
from fastapi import Body
|
| 1648 |
+
|
| 1649 |
+
@app.post("/register_user")
|
| 1650 |
+
async def register_user(request: Request, body: dict = Body(...)):
|
| 1651 |
+
auth_header = request.headers.get("Authorization", "")
|
| 1652 |
+
if not auth_header.startswith("Bearer "):
|
| 1653 |
+
raise HTTPException(401, "Missing authentication token")
|
| 1654 |
+
token = auth_header.split("Bearer ", 1)[1]
|
| 1655 |
+
try:
|
| 1656 |
+
decoded = fb_auth.verify_id_token(token)
|
| 1657 |
+
except Exception:
|
| 1658 |
+
raise HTTPException(401, "Invalid token")
|
| 1659 |
+
|
| 1660 |
+
uid = decoded["uid"]
|
| 1661 |
+
db = _init_firebase()
|
| 1662 |
+
user_ref = db.collection("users").document(uid)
|
| 1663 |
+
user_doc = user_ref.get()
|
| 1664 |
+
|
| 1665 |
+
data = {
|
| 1666 |
+
"email": decoded.get("email", ""),
|
| 1667 |
+
"display_name": body.get("display_name", ""),
|
| 1668 |
+
"updated_at": firestore.SERVER_TIMESTAMP
|
| 1669 |
+
}
|
| 1670 |
+
device_id = str(body.get("device_id", "")).strip()[:128]
|
| 1671 |
+
|
| 1672 |
+
if not user_doc.exists:
|
| 1673 |
+
data["created_at"] = firestore.SERVER_TIMESTAMP
|
| 1674 |
+
data["is_blocked"] = False
|
| 1675 |
+
data["device_ids"] = [device_id] if device_id else []
|
| 1676 |
+
user_ref.set(data)
|
| 1677 |
+
else:
|
| 1678 |
+
if device_id:
|
| 1679 |
+
existing = user_doc.to_dict() or {}
|
| 1680 |
+
existing_devices = list(existing.get("device_ids") or [])
|
| 1681 |
+
license_key = existing.get("license_key")
|
| 1682 |
+
if not license_key:
|
| 1683 |
+
data["device_ids"] = [device_id]
|
| 1684 |
+
elif device_id not in existing_devices:
|
| 1685 |
+
license_doc = db.collection("licenses").document(license_key).get()
|
| 1686 |
+
lic = license_doc.to_dict() if license_doc.exists else {}
|
| 1687 |
+
max_devices = max(1, int(lic.get("max_devices") or 1))
|
| 1688 |
+
if len(existing_devices) >= max_devices:
|
| 1689 |
+
raise HTTPException(403, "Maximum licensed devices reached")
|
| 1690 |
+
data["device_ids"] = firestore.ArrayUnion([device_id])
|
| 1691 |
+
user_ref.update(data)
|
| 1692 |
+
|
| 1693 |
+
return {"status": "success", "uid": uid}
|
| 1694 |
+
|
| 1695 |
+
@app.post("/activate_license")
|
| 1696 |
+
async def activate_license(request: Request, body: dict = Body(...)):
|
| 1697 |
+
auth_header = request.headers.get("Authorization", "")
|
| 1698 |
+
if not auth_header.startswith("Bearer "):
|
| 1699 |
+
raise HTTPException(401, "Missing token")
|
| 1700 |
+
token = auth_header.split("Bearer ", 1)[1]
|
| 1701 |
+
try:
|
| 1702 |
+
decoded = fb_auth.verify_id_token(token)
|
| 1703 |
+
except Exception:
|
| 1704 |
+
raise HTTPException(401, "Invalid token")
|
| 1705 |
+
|
| 1706 |
+
uid = decoded["uid"]
|
| 1707 |
+
license_key = body.get("license_key")
|
| 1708 |
+
if not license_key:
|
| 1709 |
+
raise HTTPException(400, "license_key is required")
|
| 1710 |
+
|
| 1711 |
+
db = _init_firebase()
|
| 1712 |
+
license_ref = db.collection("licenses").document(license_key)
|
| 1713 |
+
license_doc = license_ref.get()
|
| 1714 |
+
|
| 1715 |
+
if not license_doc.exists:
|
| 1716 |
+
raise HTTPException(404, "License key not found")
|
| 1717 |
+
|
| 1718 |
+
lic = license_doc.to_dict()
|
| 1719 |
+
current_status = lic.get("status")
|
| 1720 |
+
if current_status not in ("unused", "unclaimed", "active"):
|
| 1721 |
+
raise HTTPException(400, f"License status is {current_status}")
|
| 1722 |
+
|
| 1723 |
+
if lic.get("assigned_uid") and lic.get("assigned_uid") != uid:
|
| 1724 |
+
raise HTTPException(400, "License is already assigned to another user")
|
| 1725 |
+
|
| 1726 |
+
from datetime import datetime, timedelta, timezone
|
| 1727 |
+
|
| 1728 |
+
duration_days = lic.get("duration_days", 30)
|
| 1729 |
+
expiry_date = lic.get("expiry_date")
|
| 1730 |
+
device_id = str(request.headers.get("X-Device-ID", "")).strip()[:128]
|
| 1731 |
+
user_ref = db.collection("users").document(uid)
|
| 1732 |
+
@firestore.transactional
|
| 1733 |
+
def claim_license(transaction):
|
| 1734 |
+
fresh_snapshot = license_ref.get(transaction=transaction)
|
| 1735 |
+
if not fresh_snapshot.exists:
|
| 1736 |
+
raise HTTPException(404, "License key not found")
|
| 1737 |
+
fresh = fresh_snapshot.to_dict() or {}
|
| 1738 |
+
fresh_status = fresh.get("status")
|
| 1739 |
+
if fresh_status not in ("unused", "unclaimed", "active"):
|
| 1740 |
+
raise HTTPException(400, f"License status is {fresh_status}")
|
| 1741 |
+
assigned_uid = fresh.get("assigned_uid")
|
| 1742 |
+
if assigned_uid and assigned_uid != uid:
|
| 1743 |
+
raise HTTPException(400, "License is already assigned to another user")
|
| 1744 |
+
claimed_expiry = fresh.get("expiry_date")
|
| 1745 |
+
updates = {
|
| 1746 |
+
"status": "active",
|
| 1747 |
+
"assigned_uid": uid,
|
| 1748 |
+
"assigned_email": decoded.get("email", ""),
|
| 1749 |
+
}
|
| 1750 |
+
if not claimed_expiry:
|
| 1751 |
+
claimed_expiry = datetime.now(timezone.utc) + timedelta(
|
| 1752 |
+
days=int(fresh.get("duration_days") or duration_days)
|
| 1753 |
+
)
|
| 1754 |
+
updates["activated_at"] = firestore.SERVER_TIMESTAMP
|
| 1755 |
+
updates["expiry_date"] = claimed_expiry
|
| 1756 |
+
else:
|
| 1757 |
+
exp_dt = (
|
| 1758 |
+
claimed_expiry
|
| 1759 |
+
if getattr(claimed_expiry, "tzinfo", None)
|
| 1760 |
+
else claimed_expiry.replace(tzinfo=timezone.utc)
|
| 1761 |
+
)
|
| 1762 |
+
if exp_dt < datetime.now(timezone.utc):
|
| 1763 |
+
raise HTTPException(400, "License has expired")
|
| 1764 |
+
fresh_user_snapshot = user_ref.get(transaction=transaction)
|
| 1765 |
+
fresh_user = fresh_user_snapshot.to_dict() if fresh_user_snapshot.exists else {}
|
| 1766 |
+
device_ids = list(fresh_user.get("device_ids") or [])
|
| 1767 |
+
max_devices = max(1, int(fresh.get("max_devices") or 1))
|
| 1768 |
+
if len(device_ids) > max_devices:
|
| 1769 |
+
raise HTTPException(403, "Maximum licensed devices reached")
|
| 1770 |
+
if device_id and device_id not in device_ids:
|
| 1771 |
+
if len(device_ids) >= max_devices:
|
| 1772 |
+
raise HTTPException(403, "Maximum licensed devices reached")
|
| 1773 |
+
device_ids.append(device_id)
|
| 1774 |
+
user_clone_setting = (
|
| 1775 |
+
bool(fresh_user.get("voice_clone_enabled"))
|
| 1776 |
+
if "voice_clone_enabled" in fresh_user
|
| 1777 |
+
else bool(fresh.get("voice_clone", False))
|
| 1778 |
+
)
|
| 1779 |
+
transaction.update(license_ref, updates)
|
| 1780 |
+
transaction.set(user_ref, {
|
| 1781 |
+
"license_key": license_key,
|
| 1782 |
+
"license_status": "active",
|
| 1783 |
+
"license_expiry": claimed_expiry,
|
| 1784 |
+
"voice_clone_enabled": user_clone_setting,
|
| 1785 |
+
"device_ids": device_ids,
|
| 1786 |
+
"updated_at": firestore.SERVER_TIMESTAMP,
|
| 1787 |
+
}, merge=True)
|
| 1788 |
+
return claimed_expiry, user_clone_setting, fresh, dict(fresh_user, device_ids=device_ids)
|
| 1789 |
+
|
| 1790 |
+
expiry_date, user_clone_setting, lic, user_data = claim_license(db.transaction())
|
| 1791 |
+
|
| 1792 |
+
# User-specific pool: dedicated Spaces first, otherwise public auto pool.
|
| 1793 |
+
space_pool = _license_space_pool(db, uid, user_data, license_key, lic)
|
| 1794 |
+
|
| 1795 |
return {
|
| 1796 |
+
"status": "success",
|
| 1797 |
+
"plan_name": lic.get("plan_name", "Standard"),
|
| 1798 |
+
"expiry_date": str(expiry_date) if expiry_date else None,
|
| 1799 |
+
"voice_clone_enabled": user_clone_setting and bool(lic.get("voice_clone", False)),
|
| 1800 |
+
"app_link": lic.get("app_link") or lic.get("appLink", ""),
|
| 1801 |
+
"space_pool": space_pool
|
| 1802 |
}
|
| 1803 |
|
| 1804 |
+
@app.get("/license_status")
|
| 1805 |
+
async def license_status(request: Request):
|
| 1806 |
+
auth_header = request.headers.get("Authorization", "")
|
| 1807 |
+
if not auth_header.startswith("Bearer "):
|
| 1808 |
+
raise HTTPException(401, "Missing token")
|
| 1809 |
+
token = auth_header.split("Bearer ", 1)[1]
|
| 1810 |
+
try:
|
| 1811 |
+
decoded = fb_auth.verify_id_token(token)
|
| 1812 |
+
except Exception:
|
| 1813 |
+
raise HTTPException(401, "Invalid token")
|
| 1814 |
+
|
| 1815 |
+
uid = decoded["uid"]
|
| 1816 |
+
db = _init_firebase()
|
| 1817 |
+
user_doc = db.collection("users").document(uid).get()
|
| 1818 |
+
|
| 1819 |
+
if not user_doc.exists:
|
| 1820 |
+
return {"has_license": False, "status": "none"}
|
| 1821 |
+
|
| 1822 |
+
user_data = user_doc.to_dict()
|
| 1823 |
+
if user_data.get("is_blocked"):
|
| 1824 |
+
return {"has_license": False, "status": "blocked"}
|
| 1825 |
+
|
| 1826 |
+
license_key = user_data.get("license_key")
|
| 1827 |
+
if not license_key:
|
| 1828 |
+
return {"has_license": False, "status": "none"}
|
| 1829 |
+
|
| 1830 |
+
license_doc = db.collection("licenses").document(license_key).get()
|
| 1831 |
+
if not license_doc.exists:
|
| 1832 |
+
return {"has_license": False, "status": "none"}
|
| 1833 |
+
|
| 1834 |
+
lic = license_doc.to_dict()
|
| 1835 |
+
lic_status = lic.get("status", "active")
|
| 1836 |
+
if lic_status != "active":
|
| 1837 |
+
return {"has_license": False, "status": lic_status}
|
| 1838 |
+
|
| 1839 |
+
from datetime import datetime, timezone
|
| 1840 |
+
expiry_date = lic.get("expiry_date")
|
| 1841 |
+
is_expired = False
|
| 1842 |
+
days_remaining = 30
|
| 1843 |
+
if expiry_date:
|
| 1844 |
+
try:
|
| 1845 |
+
exp_dt = expiry_date if getattr(expiry_date, "tzinfo", None) else expiry_date.replace(tzinfo=timezone.utc)
|
| 1846 |
+
delta = exp_dt - datetime.now(timezone.utc)
|
| 1847 |
+
days_remaining = delta.days
|
| 1848 |
+
if delta.total_seconds() < 0:
|
| 1849 |
+
is_expired = True
|
| 1850 |
+
except Exception:
|
| 1851 |
+
pass
|
| 1852 |
+
|
| 1853 |
+
if is_expired:
|
| 1854 |
+
return {"has_license": False, "status": "expired", "is_expired": True}
|
| 1855 |
+
|
| 1856 |
+
# User-specific pool: dedicated Spaces first, otherwise public auto pool.
|
| 1857 |
+
space_pool = _license_space_pool(db, uid, user_data, license_key, lic)
|
| 1858 |
|
|
|
|
|
|
|
|
|
|
| 1859 |
return {
|
| 1860 |
+
"has_license": True,
|
| 1861 |
+
"status": "active",
|
| 1862 |
+
"license_key": license_key,
|
| 1863 |
+
"plan_name": lic.get("plan_name", "Pro"),
|
| 1864 |
+
"expiry_date": str(expiry_date) if expiry_date else None,
|
| 1865 |
+
"days_remaining": max(0, days_remaining),
|
| 1866 |
+
"voice_clone_enabled": bool(user_data.get("voice_clone_enabled", lic.get("voice_clone", False))) and bool(lic.get("voice_clone", False)),
|
| 1867 |
+
"app_link": lic.get("app_link") or lic.get("appLink", ""),
|
| 1868 |
+
"space_pool": space_pool,
|
| 1869 |
+
"is_expired": False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1870 |
}
|
| 1871 |
|
| 1872 |
+
@app.get("/status")
|
| 1873 |
+
def status():
|
| 1874 |
+
return {"status": "ok"}
|
| 1875 |
+
|
| 1876 |
+
|
| 1877 |
+
@app.get("/health")
|
| 1878 |
+
@app.head("/health")
|
| 1879 |
+
def health():
|
| 1880 |
+
with _SPACE_STATE_LOCK:
|
| 1881 |
+
active_jobs = _ACTIVE_JOBS
|
| 1882 |
+
policy = get_runtime_policy()
|
| 1883 |
+
clone_status = clone_backend_status()
|
| 1884 |
+
firebase_configured = bool(HAS_FIREBASE and _init_firebase())
|
| 1885 |
+
return {
|
| 1886 |
+
"status": "ok",
|
| 1887 |
+
"space_id": SPACE_ID,
|
| 1888 |
+
"firebase_configured": firebase_configured,
|
| 1889 |
+
"active_jobs": active_jobs,
|
| 1890 |
+
"max_concurrent_jobs": MAX_CONCURRENT_JOBS,
|
| 1891 |
+
"available": active_jobs < MAX_CONCURRENT_JOBS and bool(policy.get("space_enabled", True)) and not bool(policy.get("maintenance_mode", False)),
|
| 1892 |
+
"clone_enabled": bool(policy.get("space_clone_enabled", False)),
|
| 1893 |
+
"clone_ready": bool(clone_status.get("ready", False)),
|
| 1894 |
+
"clone_status": clone_status.get("message", "unknown"),
|
| 1895 |
+
"engines": BASE_ENGINES + (CLONE_ENGINES if policy.get("space_clone_enabled", False) else []),
|
| 1896 |
+
"maintenance_mode": bool(policy.get("maintenance_mode", False)),
|
| 1897 |
+
"version": SERVICE_VERSION,
|
| 1898 |
+
}
|
| 1899 |
+
|
| 1900 |
|
| 1901 |
@app.get("/all_voices")
|
| 1902 |
+
async def all_voices_list(request: Request):
|
| 1903 |
+
"""All configured Edge, Piper, Silero, and CPU voice-cloning voices."""
|
| 1904 |
+
await authorize_request(request, "edge")
|
| 1905 |
result = {"edge": {}, "piper": {}, "silero": {}}
|
| 1906 |
|
| 1907 |
+
# Current Edge catalog. Desktop defaults to a smaller Featured view.
|
| 1908 |
try:
|
| 1909 |
import edge_tts
|
| 1910 |
voices = await edge_tts.list_voices()
|
|
|
|
| 1926 |
except Exception as e:
|
| 1927 |
print(f"Edge voices error: {e}")
|
| 1928 |
|
| 1929 |
+
# Curated Piper catalog only; arbitrary repository models are not exposed.
|
| 1930 |
+
piper_display_names = {
|
| 1931 |
+
"piper:en_US-amy-medium": "Amy [en-US]",
|
| 1932 |
+
"piper:en_US-joe-medium": "Joe [en-US]",
|
| 1933 |
+
"piper:en_US-lessac-medium": "Lessac HQ [en-US]",
|
| 1934 |
+
"piper:en_US-ryan-high": "Ryan HQ [en-US]",
|
| 1935 |
+
"piper:en_GB-alan-medium": "Alan [en-GB]",
|
| 1936 |
+
"piper:en_GB-alba-medium": "Alba [en-GB]",
|
| 1937 |
+
"piper:ur_PK-fasih-medium": "Fasih [ur-PK]",
|
| 1938 |
+
"piper:ar_JO-kareem-medium": "Kareem [ar-JO]",
|
| 1939 |
+
"piper:hi_IN-pratham-medium": "Pratham [hi-IN]",
|
| 1940 |
+
"piper:de_DE-thorsten-medium": "Thorsten [de-DE]",
|
| 1941 |
+
"piper:ru_RU-irina-medium": "Irina [ru-RU]",
|
| 1942 |
+
"piper:fr_FR-upmc-medium": "UPMC HQ [fr-FR]",
|
| 1943 |
+
"piper:pt_BR-faber-medium": "Faber [pt-BR]",
|
| 1944 |
+
"piper:tr_TR-dfki-medium": "Dfki [tr-TR]",
|
| 1945 |
+
"piper:nl_NL-mls-medium": "MLS [nl-NL]",
|
| 1946 |
+
}
|
| 1947 |
+
for code in PIPER_VOICES:
|
| 1948 |
+
result["piper"][piper_display_names[code]] = code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1949 |
|
| 1950 |
# Silero v4 — Russian (official v4_ru speakers)
|
| 1951 |
silero_ru_speakers = {
|
|
|
|
| 1955 |
for speaker_code, display_name in silero_ru_speakers.items():
|
| 1956 |
result["silero"][f"{display_name} \u2022 Russian [RU]"] = f"silero:ru_{speaker_code}"
|
| 1957 |
|
| 1958 |
+
if not clone_backend_available():
|
| 1959 |
+
total = sum(len(group) for group in result.values())
|
| 1960 |
+
return {"voices": result, "total": total, "clone_enabled": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1961 |
|
| 1962 |
result["f5tts"] = {"Voice Clone": "f5tts:v1_base"}
|
| 1963 |
total = sum(len(group) for group in result.values())
|
|
|
|
| 1968 |
"edge": "en-US-AvaNeural",
|
| 1969 |
"piper": "piper:en_US-amy-medium",
|
| 1970 |
"silero": "silero:ru_xenia",
|
|
|
|
| 1971 |
"f5tts": "f5tts:v1_base",
|
| 1972 |
}
|
| 1973 |
|
|
|
|
| 2047 |
audio = await asyncio.to_thread(synthesize_silero, clean_text, voice)
|
| 2048 |
return _write_audio_file(audio, ".wav"), "Ready - Silero audio generated."
|
| 2049 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2050 |
if engine == "f5tts":
|
| 2051 |
audio = await synthesize_f5tts(clean_text, reference_path=reference_path)
|
| 2052 |
return _write_audio_file(audio, ".wav"), "Ready - voice clone generated."
|
|
|
|
| 2110 |
|
| 2111 |
@app.get("/")
|
| 2112 |
def root():
|
| 2113 |
+
return {"name": "VoiceCraft Service", "status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2114 |
|
| 2115 |
|
| 2116 |
if __name__ == "__main__":
|
|
|
|
| 2119 |
server_name="0.0.0.0",
|
| 2120 |
server_port=port,
|
| 2121 |
share=False,
|
| 2122 |
+
show_error=False,
|
| 2123 |
)
|
requirements-docker.txt
CHANGED
|
@@ -8,6 +8,7 @@ numpy>=2.0,<3
|
|
| 8 |
scipy>=1.15.3
|
| 9 |
soundfile>=0.12,<1
|
| 10 |
pocket-tts>=2.1.0
|
|
|
|
| 11 |
|
| 12 |
# Docker profile is now CPU clone first. Silero v4 may be unavailable because
|
| 13 |
# Pocket TTS requires a newer PyTorch runtime.
|
|
|
|
| 8 |
scipy>=1.15.3
|
| 9 |
soundfile>=0.12,<1
|
| 10 |
pocket-tts>=2.1.0
|
| 11 |
+
firebase-admin>=6.0.0
|
| 12 |
|
| 13 |
# Docker profile is now CPU clone first. Silero v4 may be unavailable because
|
| 14 |
# Pocket TTS requires a newer PyTorch runtime.
|
requirements.txt
CHANGED
|
@@ -8,3 +8,5 @@ numpy>=2.0,<3
|
|
| 8 |
scipy>=1.15.3
|
| 9 |
soundfile>=0.12,<1
|
| 10 |
pocket-tts>=2.1.0
|
|
|
|
|
|
|
|
|
| 8 |
scipy>=1.15.3
|
| 9 |
soundfile>=0.12,<1
|
| 10 |
pocket-tts>=2.1.0
|
| 11 |
+
firebase-admin>=6.0.0
|
| 12 |
+
|