razapro857 commited on
Commit
b28d894
·
verified ·
1 Parent(s): 2e2dc4f

Upload 6 files

Browse files
Files changed (4) hide show
  1. README.md +28 -6
  2. app.py +1170 -416
  3. requirements-docker.txt +1 -0
  4. 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, Anime presets
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
- Optional modes:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- app = gr.Server(
 
 
 
28
  title="VoiceCraft TTS Server",
29
- description="VoiceCraft desktop API with CPU voice cloning support.",
30
- version="2.0",
 
 
 
 
31
  )
32
  app.add_middleware(GZipMiddleware, minimum_size=1000)
33
 
34
 
35
  # ══════════════════════════════════════════════════════════════════
36
- # SECURITY — server-side authorization
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", "anime"]
 
 
 
 
 
 
 
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 available_engines():
88
- return BASE_ENGINES + (CLONE_ENGINES if clone_engines_enabled() else [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- def _license_cache_key(license_key: str, device_id: str) -> str:
114
- raw = f"{license_key}\0{device_id}".encode("utf-8")
115
- return hashlib.sha256(raw).hexdigest()
116
-
117
-
118
- def _license_flag(value) -> bool:
119
- return str(value or "").strip().lower() in {
120
- "active", "enabled", "enable", "on", "true", "yes", "1"
121
- }
122
-
123
-
124
- def _version_tuple(value: str) -> tuple[int, ...]:
125
- parts = re.findall(r"\d+", str(value or ""))
126
- numbers = [int(part) for part in parts[:4]]
127
- return tuple((numbers + [0, 0, 0, 0])[:4])
128
-
129
-
130
- def _validate_license_sync(license_key: str, device_id: str) -> dict:
131
- cache_key = _license_cache_key(license_key, device_id)
132
- now = time.monotonic()
133
- with _LICENSE_CACHE_LOCK:
134
- cached = _LICENSE_CACHE.get(cache_key)
135
- if cached and cached["expires_at"] > now:
136
- return cached["payload"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  try:
138
- response = requests.get(
139
- LICENSE_VALIDATION_URL,
140
- params={
141
- "action": "check",
142
- "license_key": license_key,
143
- "pc_user": "VoiceCraftDesktop",
144
- "device_id": device_id,
145
- },
146
- headers={"User-Agent": "VoiceCraft-Server/3.0"},
147
- timeout=(6, 20),
148
- )
149
- response.raise_for_status()
150
- payload = response.json()
151
- except Exception as exc:
152
- logging.warning("License validation service unavailable: %s", exc.__class__.__name__)
153
- raise HTTPException(status_code=503, detail="License validation service unavailable")
154
- status = str(payload.get("status", "") or "").strip().lower()
155
- if status not in {"active", "trial"}:
156
- message = str(payload.get("message", "") or "").strip()
157
- if status == "device_limit":
158
- detail = message or "Maximum device limit reached for this license"
159
- elif status:
160
- detail = message or f"License is {status}"
161
- else:
162
- detail = "License is not active"
163
- raise HTTPException(status_code=403, detail=detail)
164
- ttl = _bounded_env_int("LICENSE_CACHE_TTL_SECONDS", 45, 5, 300)
165
- with _LICENSE_CACHE_LOCK:
166
- _LICENSE_CACHE[cache_key] = {
167
- "payload": payload,
168
- "expires_at": now + ttl,
169
- }
170
- if len(_LICENSE_CACHE) > 2048:
171
- expired = [
172
- key for key, value in _LICENSE_CACHE.items()
173
- if value["expires_at"] <= now
174
- ]
175
- for key in expired:
176
- _LICENSE_CACHE.pop(key, None)
177
- return payload
178
-
179
-
180
- def _enforce_rate_limit(license_key: str, device_id: str, is_clone: bool):
181
- identity = _license_cache_key(license_key, device_id)
 
 
 
 
 
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"{identity}:{'clone' if is_clone else 'tts'}"
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
- async def authorize_request(request: Request, engine: str) -> dict:
 
 
 
 
 
 
204
  if AUTH_MODE in {"api_secret", "layered"}:
205
  verify_token(request)
206
- if AUTH_MODE == "api_secret":
207
- return {}
208
- license_key = request.headers.get("X-License-Key", "").strip()
209
- device_id = request.headers.get("X-Device-ID", "").strip()
210
- client_version = request.headers.get("X-Client-Version", "").strip()
211
- if _version_tuple(client_version) < _version_tuple(MIN_CLIENT_VERSION):
212
- raise HTTPException(status_code=426, detail="VoiceCraft update required")
213
- if not license_key or not device_id:
214
- raise HTTPException(status_code=401, detail="License credentials are required")
215
- if len(license_key) > 256 or len(device_id) > 128:
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
- if is_clone and not _license_flag(payload.get("voiceClone")):
220
- raise HTTPException(status_code=403, detail="Voice cloning is not enabled for this license")
221
- _enforce_rate_limit(license_key, device_id, is_clone)
222
- return payload
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 download_piper_dynamic(voice_code: str) -> tuple:
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 _ffmpeg_concat_wav(parts: list) -> bytes:
395
- """Merge multiple WAV byte chunks using ffmpeg concat (same codec)."""
396
  if len(parts) == 1:
397
  return parts[0]
398
- tmp_files, concat_list, out_path = [], None, None
399
- try:
400
- for data in parts:
401
- fd, path = tempfile.mkstemp(suffix=".wav")
402
- os.close(fd)
403
- with open(path, "wb") as f:
404
- f.write(data)
405
- tmp_files.append(path)
406
- fd, concat_list = tempfile.mkstemp(suffix=".txt")
407
  os.close(fd)
408
- with open(concat_list, "w") as f:
409
- for p in tmp_files:
410
- f.write(f"file '{p}'\n")
411
- fd, out_path = tempfile.mkstemp(suffix=".wav")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  os.close(fd)
413
- subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
414
- "-f", "concat", "-safe", "0", "-i", concat_list,
415
- "-c", "copy", out_path], check=True, timeout=60, creationflags=CREATE_NO_WINDOW)
416
- with open(out_path, "rb") as f:
417
- return f.read()
418
- except Exception:
419
- return b"".join(parts)
420
- finally:
421
- for p in tmp_files:
422
- try: os.unlink(p)
423
- except Exception: pass
424
- for x in (concat_list, out_path):
425
- if x:
426
- try: os.unlink(x)
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
- # Pehle PIPER_VOICES dict mein check karo, nahi mila to dynamic download
434
- if voice_code in PIPER_VOICES:
435
- onnx_path, json_path = download_piper_model(voice_code)
436
- else:
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 = text.strip()
459
  if not text:
460
  return []
461
  sentence_re = re.compile(
462
- r'(?:(?<=[.!?\u0964\u06D4\u061F\u2026])\s+)|(?<=[\u3002\uff01\uff1f])'
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
- """Merge multiple MP3 byte chunks using ffmpeg concat."""
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(1 + attempt)
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
- _POCKET_MODEL_LOCK = threading.Lock()
 
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 _load_pocket_model():
739
- global _POCKET_MODEL, _POCKET_MODEL_ERROR
740
- if _POCKET_MODEL is not None:
741
- return _POCKET_MODEL
742
- with _POCKET_MODEL_LOCK:
743
- if _POCKET_MODEL is not None:
744
- return _POCKET_MODEL
745
- try:
746
- from pocket_tts import TTSModel
747
- _POCKET_MODEL = TTSModel.load_model()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return _POCKET_RESULT_CACHE.get(cache_key)
 
 
 
 
 
 
 
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
- return _POCKET_VOICE_STATE_CACHE.get(reference_key)
 
 
 
 
 
 
 
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
- @app.api(name="voicecraft_clone_backend", api_visibility="private", concurrency_limit=1)
874
- def _run_pocket_clone_cpu(text: str, reference_path: str, reference_key: str) -> bytes:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- for chunk in chunks:
896
- audio = model.generate_audio(voice_state, chunk)
897
- if hasattr(audio, "detach"):
898
- audio = audio.detach().cpu().numpy()
899
- audio_np = np.asarray(audio).squeeze().reshape(-1)
900
- if audio_np.size:
901
- audio_parts.append(audio_np)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if not reference_path or not os.path.exists(reference_path):
924
- raise ValueError("A reference audio file is required for voice cloning")
925
- reference_key = await asyncio.to_thread(_reference_audio_key, reference_path)
926
- cache_key = _clone_request_key(text, reference_key)
927
- cached = _get_cached_clone(cache_key)
928
- if cached is not None:
929
- return cached
930
- if _get_cached_voice_state(reference_key) is not None:
931
- # Same reference voice is already encoded. Skip ffmpeg normalization for repeated
932
- # desktop batches/generations; audio quality is unchanged because voice_state is reused.
933
- audio = await asyncio.to_thread(
934
- _run_pocket_clone_cpu, text, reference_path, reference_key
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
935
  )
936
- else:
937
- normalized_path = await asyncio.to_thread(_normalize_clone_reference, reference_path)
938
- try:
939
- audio = await asyncio.to_thread(
940
- _run_pocket_clone_cpu, text, normalized_path, reference_key
941
- )
942
- finally:
943
- try:
944
- os.unlink(normalized_path)
945
- except OSError:
946
- pass
947
- _cache_clone(cache_key, audio)
948
- return audio
949
-
950
- async def synthesize_anime(text: str, voice_code: str, style: str = None) -> bytes:
951
- """Anime-style TTS using reliable Edge voices until a custom anime backend is configured."""
952
- voice_map = {
953
- "anime:en_whisper": ("en-US-AvaMultilingualNeural", "whispering", "+8%", "+4%", "+4Hz"),
954
- "anime:ja_edge": ("ja-JP-NanamiNeural", "cheerful", "+10%", "+5%", "+5Hz"),
955
- "anime:zh_piper": ("zh-CN-XiaoxiaoNeural", "cheerful", "+10%", "+5%", "+5Hz"),
956
- }
957
- edge_voice, fallback_style, rate, volume, pitch = voice_map.get(
958
- voice_code,
959
- ("en-US-AvaMultilingualNeural", "cheerful", "+10%", "+5%", "+5Hz"),
960
- )
961
- return await synthesize_edge(
962
- text,
963
- edge_voice,
964
- rate=rate,
965
- volume=volume,
966
- pitch=pitch,
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" | "anime" | "f5tts"
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
- await authorize_request(request, engine)
999
-
1000
- if engine in CLONE_ENGINES and not clone_engines_enabled():
1001
- return JSONResponse(
1002
- status_code=403,
1003
- content={"error": "Clone engines are disabled on this Space"},
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
- try:
1018
- if reference_audio is not None and reference_audio.filename:
 
 
 
 
 
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
- return StreamingResponse(
 
 
 
 
 
 
 
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": f"Synthesis failed: {str(e)[:240]}"},
1074
  )
1075
- finally:
1076
- try:
 
 
 
 
 
 
 
 
 
 
1077
  if reference_path and os.path.exists(reference_path):
1078
  os.unlink(reference_path)
1079
  except Exception:
1080
  pass
1081
 
1082
 
1083
- @app.get("/status")
1084
- def status():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1085
  return {
1086
- "status": "VoiceCraft TTS Server OK",
1087
- "engines": available_engines(),
1088
- "clone_enabled": clone_engines_enabled(),
 
 
 
1089
  }
1090
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1091
 
1092
- @app.get("/health")
1093
- @app.head("/health")
1094
- def health():
1095
  return {
1096
- "status": "ok",
1097
- "piper_ready": PIPER_READY,
1098
- "silero_ready": SILERO_READY,
1099
- "silero_speakers": SILERO_SPEAKERS_RU,
1100
- "engines": available_engines(),
1101
- "clone_enabled": clone_engines_enabled(),
1102
- "zerogpu_enabled": False,
1103
- "clone_backend": CLONE_BACKEND_NAME,
1104
- "auth_mode": AUTH_MODE,
1105
- "api_auth_required": AUTH_MODE in {"api_secret", "layered"},
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 voices - Edge, Piper, Silero, Anime styles, and CPU voice cloning."""
 
1117
  result = {"edge": {}, "piper": {}, "silero": {}}
1118
 
1119
- # Edge TTS — 400+ voices (complete list, clean naming)
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 TTS — all voices (clean naming, no engine hints)
1142
- try:
1143
- import urllib.request, json as _json
1144
- api_url = "https://huggingface.co/api/models/rhasspy/piper-voices"
1145
- req = urllib.request.Request(api_url, headers={"User-Agent": "VoiceCraft/2.0"})
1146
- with urllib.request.urlopen(req, timeout=60) as resp:
1147
- data = _json.loads(resp.read())
1148
- siblings = data.get("siblings", [])
1149
- for s in siblings:
1150
- rfn = s.get("rfilename", s.get("rfn", ""))
1151
- if not rfn.endswith(".onnx") or ".json" in rfn or "samples" in rfn:
1152
- continue
1153
- parts = rfn.split("/")
1154
- if len(parts) < 2:
1155
- continue
1156
- model_name = parts[-1].replace(".onnx", "")
1157
- dash_parts = model_name.rsplit("-", 2)
1158
- if len(dash_parts) >= 3:
1159
- lang_code = dash_parts[0].replace("_", "-")
1160
- voice = dash_parts[1].replace("_", " ").title()
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
- # Anime styles are lightweight Edge-based presets and remain CPU-safe.
1185
- anime_voices = {
1186
- "Anime Whisper [en]": "anime:en_whisper",
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=True,
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
+