razapro857 commited on
Commit
4e86bfc
·
verified ·
1 Parent(s): 1ba05a7

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile +13 -6
  2. README.docker.md +39 -0
  3. README.md +69 -10
  4. app.py +1369 -646
  5. requirements-docker.txt +13 -0
  6. requirements.txt +6 -4
Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- FROM python:3.10-slim
2
 
3
  # System tools
4
  RUN apt-get update && apt-get install -y \
@@ -7,16 +7,23 @@ RUN apt-get update && apt-get install -y \
7
  && rm -rf /var/lib/apt/lists/*
8
 
9
  WORKDIR /app
 
 
10
 
11
- # Install Python deps (includes torch==2.1.2 needed for Silero v4 PackageImporter,
12
- # removed in torch 2.3+. PyPI torch wheels are CPU-only by default.)
13
- COPY requirements.txt .
14
- RUN pip install --no-cache-dir -r requirements.txt
 
 
 
 
 
15
 
16
  # Copy app
17
  COPY app.py .
18
 
19
- # HuggingFace Spaces runs on port 7860
20
  EXPOSE 7860
21
 
22
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ FROM python:3.10-slim-bookworm
2
 
3
  # System tools
4
  RUN apt-get update && apt-get install -y \
 
7
  && rm -rf /var/lib/apt/lists/*
8
 
9
  WORKDIR /app
10
+ ENV ENABLE_CLONE_ENGINES=1
11
+ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
12
 
13
+ # Docker profile runs the CPU voice-clone backend.
14
+ COPY requirements*.txt ./
15
+ RUN pip install --no-cache-dir --upgrade pip && \
16
+ pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu "torch==2.5.1+cpu" && \
17
+ if [ -f requirements-docker.txt ]; then \
18
+ pip install --no-cache-dir -r requirements-docker.txt; \
19
+ else \
20
+ pip install --no-cache-dir -r requirements.txt; \
21
+ fi
22
 
23
  # Copy app
24
  COPY app.py .
25
 
26
+ # Hugging Face Spaces runs on port 7860
27
  EXPOSE 7860
28
 
29
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.docker.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: VoiceCraft CPU Clone Server
3
+ emoji: 🎙️
4
+ colorFrom: gray
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+ # VoiceCraft CPU Clone Server
11
+
12
+ Use this README content for CPU/Docker Spaces.
13
+
14
+ Settings:
15
+
16
+ - `VOICECRAFT_AUTH_MODE=license`
17
+ - `LICENSE_VALIDATION_URL` - deployed Google Apps Script URL
18
+ - `ENABLE_CLONE_ENGINES=1`
19
+ - `HF_TOKEN` - Hugging Face read token from an account that accepted the Pocket TTS model terms.
20
+
21
+ `API_SECRET` is legacy compatibility only and should not be distributed in desktop clients. Use `VOICECRAFT_AUTH_MODE=layered` only during migration, then rotate/remove the old shared secret.
22
+
23
+ Voice clone access:
24
+
25
+ 1. Open `https://huggingface.co/kyutai/pocket-tts`.
26
+ 2. Accept the model terms/access form.
27
+ 3. Create a read token at `https://huggingface.co/settings/tokens`.
28
+ 4. Add it to this Space as `HF_TOKEN`.
29
+ 5. Restart or rebuild the Space.
30
+
31
+ In the Google Sheet row for this Space:
32
+
33
+ - `AppLink` = this Docker Space URL
34
+ - `VoiceClone` = `active`, `enabled`, `on`, or `true`
35
+ - Configure `DeviceID` and `MaxDevices` for device binding.
36
+
37
+ This Space serves `/tts`, `/health`, `/status`, and `/all_voices`. `/tts` validates the license and registered device server-side; clone access also requires the row's `VoiceClone` entitlement. Voice cloning runs on CPU through Pocket TTS and does not use ZeroGPU quota.
38
+
39
+ Voice Clone accepts long scripts from the desktop app. For best speed, keep each generation under 10,000 characters; longer scripts are batched automatically and joined into one WAV.
README.md CHANGED
@@ -1,10 +1,69 @@
1
- ---
2
- title: '3'
3
- emoji: 📊
4
- colorFrom: blue
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: VoiceCraft CPU Clone Server
3
+ emoji: 🎙️
4
+ colorFrom: gray
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ startup_duration_timeout: 1h
9
+ ---
10
+
11
+ # VoiceCraft CPU Clone Server
12
+
13
+ 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
+
21
+ ## Required Space settings
22
+
23
+ Set these in Space settings:
24
+
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.
36
+
37
+ Rate limits default to 60 TTS requests/minute and 8 clone requests/minute per license/device. Override with `TTS_REQUESTS_PER_MINUTE` and `CLONE_REQUESTS_PER_MINUTE`.
38
+
39
+ ## Voice clone access
40
+
41
+ Pocket TTS voice cloning uses gated weights. Before using custom reference audio:
42
+
43
+ 1. Open `https://huggingface.co/kyutai/pocket-tts` while logged in.
44
+ 2. Accept the model terms/access form.
45
+ 3. Create a read token at `https://huggingface.co/settings/tokens`.
46
+ 4. Add that token to this Space as the secret `HF_TOKEN`.
47
+ 5. Restart or rebuild the Space.
48
+
49
+ ## Google Sheet row
50
+
51
+ For a clone-enabled Space:
52
+
53
+ - `AppLink` = this Space URL
54
+ - `VoiceClone` = `active`, `enabled`, `on`, or `true`
55
+ - `DeviceID` and `MaxDevices` should be enabled for device binding.
56
+
57
+ For a TTS-only Space:
58
+
59
+ - `VoiceClone` = `inactive`, `disabled`, `off`, or blank
60
+
61
+ ## Notes
62
+
63
+ The desktop app still sends the clone engine as `f5tts` for compatibility, but this backend runs Pocket TTS on CPU. The public voice list only shows `Voice Clone`, so the model name is not shown in the desktop GUI.
64
+
65
+ First startup can be slower while model files download and load. Repeated use with the same reference audio is faster because the backend caches the reference voice state.
66
+
67
+ Voice Clone accepts long scripts from the desktop app. For best speed, keep each generation under 10,000 characters; longer scripts are batched automatically and joined into one WAV.
68
+
69
+ Authorization decisions are enforced by the backend. Hiding or unlocking a desktop UI control is never treated as authorization.
app.py CHANGED
@@ -1,646 +1,1369 @@
1
- import os, io, asyncio, tempfile, threading, re, subprocess, shutil, logging, secrets
2
- from fastapi import FastAPI, Form, Request, HTTPException
3
- from fastapi.responses import StreamingResponse, JSONResponse
4
- from fastapi.middleware.gzip import GZipMiddleware
5
-
6
- app = FastAPI()
7
- app.add_middleware(GZipMiddleware, minimum_size=1000)
8
-
9
- # ══════════════════════════════════════════════════════════════════
10
- # SECURITY — Token check
11
- # Set API_SECRET environment variable in HuggingFace Space settings
12
- # ══════════════════════════════════════════════════════════════════
13
- API_SECRET = os.environ.get("API_SECRET", "")
14
-
15
- def verify_token(request: Request):
16
- if not API_SECRET:
17
- logging.warning("⚠️ API_SECRET not set — rejecting request")
18
- raise HTTPException(status_code=503, detail="Server not configured")
19
- token = request.headers.get("X-API-Token", "")
20
- if not secrets.compare_digest(token, API_SECRET):
21
- raise HTTPException(status_code=403, detail="Unauthorized")
22
-
23
- # ══════════════════════════════════════════════════════════════════
24
- # PIPER TTS SETUP — Auto download on first run
25
- # ══════════════════════════════════════════════════════════════════
26
- PIPER_DIR = "/tmp/piper"
27
- PIPER_BIN = os.path.join(PIPER_DIR, "piper")
28
- PIPER_MODELS_DIR = "/tmp/piper_models"
29
- PIPER_READY = False
30
-
31
- PIPER_VOICES = {
32
- # English
33
- "piper:en_US-amy-medium": ("en_US-amy-medium.onnx", "en_US-amy-medium.onnx.json"),
34
- "piper:en_US-joe-medium": ("en_US-joe-medium.onnx", "en_US-joe-medium.onnx.json"),
35
- "piper:en_US-lessac-medium": ("en_US-lessac-medium.onnx", "en_US-lessac-medium.onnx.json"),
36
- "piper:en_US-ryan-high": ("en_US-ryan-high.onnx", "en_US-ryan-high.onnx.json"),
37
- "piper:en_GB-alan-medium": ("en_GB-alan-medium.onnx", "en_GB-alan-medium.onnx.json"),
38
- "piper:en_GB-alba-medium": ("en_GB-alba-medium.onnx", "en_GB-alba-medium.onnx.json"),
39
- # Urdu / Hindi / Arabic
40
- "piper:ur_PK-fasih-medium": ("ur_PK-fasih-medium.onnx", "ur_PK-fasih-medium.onnx.json"),
41
- "piper:hi_IN-pratham-medium": ("hi_IN-pratham-medium.onnx", "hi_IN-pratham-medium.onnx.json"),
42
- "piper:ar_JO-kareem-medium": ("ar_JO-kareem-medium.onnx", "ar_JO-kareem-medium.onnx.json"),
43
- # Other languages
44
- "piper:de_DE-thorsten-medium": ("de_DE-thorsten-medium.onnx", "de_DE-thorsten-medium.onnx.json"),
45
- "piper:fr_FR-upmc-medium": ("fr_FR-upmc-medium.onnx", "fr_FR-upmc-medium.onnx.json"),
46
- "piper:ru_RU-irina-medium": ("ru_RU-irina-medium.onnx", "ru_RU-irina-medium.onnx.json"),
47
- "piper:tr_TR-dfki-medium": ("tr_TR-dfki-medium.onnx", "tr_TR-dfki-medium.onnx.json"),
48
- "piper:pt_BR-faber-medium": ("pt_BR-faber-medium.onnx", "pt_BR-faber-medium.onnx.json"),
49
- "piper:nl_NL-mls-medium": ("nl_NL-mls-medium.onnx", "nl_NL-mls-medium.onnx.json"),
50
- }
51
-
52
- PIPER_BASE_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main"
53
-
54
- def setup_piper():
55
- global PIPER_READY
56
- try:
57
- import platform
58
- os.makedirs(PIPER_DIR, exist_ok=True)
59
- os.makedirs(PIPER_MODELS_DIR, exist_ok=True)
60
-
61
- system = platform.system().lower()
62
- arch = platform.machine().lower()
63
-
64
- if system == "linux" and "x86" in arch:
65
- piper_url = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz"
66
- elif system == "linux" and "aarch" in arch:
67
- piper_url = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_aarch64.tar.gz"
68
- else:
69
- print(f"⚠️ Piper: unsupported platform {system}/{arch}, Piper disabled")
70
- return
71
-
72
- if not os.path.exists(PIPER_BIN):
73
- print("📥 Piper binary indiriliyor...")
74
- import urllib.request
75
- tar_path = "/tmp/piper.tar.gz"
76
- urllib.request.urlretrieve(piper_url, tar_path)
77
- import tarfile
78
- with tarfile.open(tar_path, "r:gz") as tf:
79
- tf.extractall("/tmp/piper_extract")
80
- extracted = "/tmp/piper_extract/piper"
81
- if os.path.isdir(extracted):
82
- for item in os.listdir(extracted):
83
- shutil.move(os.path.join(extracted, item), os.path.join(PIPER_DIR, item))
84
- else:
85
- shutil.move(extracted, PIPER_BIN)
86
- os.chmod(PIPER_BIN, 0o755)
87
- print("✅ Piper binary ready")
88
-
89
- PIPER_READY = True
90
- print("✅ Piper TTS ready")
91
- except Exception as e:
92
- print(f"⚠️ Piper setup failed (non-critical): {e}")
93
- PIPER_READY = False
94
-
95
- threading.Thread(target=setup_piper, daemon=True).start()
96
-
97
-
98
- def download_piper_model(voice_code: str) -> tuple:
99
- """Model yoksa indir, path tuple dondur (onnx, json)"""
100
- if voice_code not in PIPER_VOICES:
101
- raise ValueError(f"Unknown Piper voice: {voice_code}")
102
- onnx_file, json_file = PIPER_VOICES[voice_code]
103
- onnx_path = os.path.join(PIPER_MODELS_DIR, onnx_file)
104
- json_path = os.path.join(PIPER_MODELS_DIR, json_file)
105
-
106
- import urllib.request
107
- # Build correct HF path: en/en_US/amy/medium/en_US-amy-medium.onnx
108
- parts = onnx_file.rsplit("-", 2)
109
- lang_code = parts[0] # en_US
110
- voice = parts[1] # amy
111
- quality = parts[2].replace(".onnx", "") # medium
112
- lang_short = lang_code.split("_")[0] # en
113
- hf_dir = f"{lang_short}/{lang_code}/{voice}/{quality}"
114
-
115
- for fname, fpath in [(onnx_file, onnx_path), (json_file, json_path)]:
116
- if not os.path.exists(fpath):
117
- url = f"{PIPER_BASE_URL}/{hf_dir}/{fname}"
118
- print(f"📥 Downloading Piper model: {fname}")
119
- try:
120
- urllib.request.urlretrieve(url, fpath)
121
- except Exception:
122
- url2 = f"{PIPER_BASE_URL}/{lang_short}/{lang_code}/{fname}"
123
- urllib.request.urlretrieve(url2, fpath)
124
- return onnx_path, json_path
125
-
126
-
127
- def download_piper_dynamic(voice_code: str) -> tuple:
128
- """Dynamic Piper voice download — code format: piper:ar_JO-kareem-low"""
129
- model_name = voice_code.replace("piper:", "") # ar_JO-kareem-low
130
- # Prevent path traversal
131
- if ".." in model_name or "/" in model_name or "\\" in model_name:
132
- raise ValueError(f"Invalid voice code: {voice_code}")
133
- onnx_file = f"{model_name}.onnx"
134
- json_file = f"{model_name}.onnx.json"
135
- onnx_path = os.path.join(PIPER_MODELS_DIR, onnx_file)
136
- json_path = os.path.join(PIPER_MODELS_DIR, json_file)
137
-
138
- if os.path.exists(onnx_path) and os.path.exists(json_path):
139
- return onnx_path, json_path
140
-
141
- import urllib.request
142
- # Model format: lang_code-voice_name-quality (e.g., ar_JO-kareem-low)
143
- # HF path: ar/ar_JO/kareem/low/ar_JO-kareem-low.onnx
144
- dash_parts = model_name.rsplit("-", 2) # Split from right: ["ar_JO", "kareem", "low"]
145
- if len(dash_parts) >= 3:
146
- lang_code = dash_parts[0] # ar_JO
147
- voice = dash_parts[1] # kareem
148
- quality = dash_parts[2] # low
149
- lang = lang_code.split("_")[0] # ar
150
- hf_path = f"{lang}/{lang_code}/{voice}/{quality}"
151
- else:
152
- hf_path = f"{model_name}/{model_name}"
153
-
154
- for fname, fpath in [(onnx_file, onnx_path), (json_file, json_path)]:
155
- if not os.path.exists(fpath):
156
- url = f"{PIPER_BASE_URL}/{hf_path}/{fname}"
157
- print(f"📥 Downloading dynamic Piper model: {fname}")
158
- try:
159
- urllib.request.urlretrieve(url, fpath)
160
- except Exception as e:
161
- print(f"⚠️ Dynamic Piper download failed: {e}")
162
- raise
163
- return onnx_path, json_path
164
-
165
-
166
- def _piper_synth_chunk(text: str, onnx_path: str, json_path: str, length_scale: float) -> bytes:
167
- with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as out_f:
168
- out_path = out_f.name
169
- try:
170
- cmd = [
171
- PIPER_BIN,
172
- "--model", onnx_path,
173
- "--config", json_path,
174
- "--output_file", out_path,
175
- "--length_scale", str(round(length_scale, 2)),
176
- ]
177
- result = subprocess.run(
178
- cmd,
179
- input=text.encode("utf-8"),
180
- capture_output=True,
181
- timeout=120,
182
- )
183
- if result.returncode != 0:
184
- raise Exception(f"Piper error: {result.stderr.decode()[:200]}")
185
- with open(out_path, "rb") as f:
186
- return f.read()
187
- finally:
188
- if os.path.exists(out_path):
189
- os.unlink(out_path)
190
-
191
-
192
- def _ffmpeg_concat_wav(parts: list) -> bytes:
193
- """Merge multiple WAV byte chunks using ffmpeg concat (same codec)."""
194
- if len(parts) == 1:
195
- return parts[0]
196
- tmp_files, concat_list, out_path = [], None, None
197
- try:
198
- for data in parts:
199
- fd, path = tempfile.mkstemp(suffix=".wav")
200
- os.close(fd)
201
- with open(path, "wb") as f:
202
- f.write(data)
203
- tmp_files.append(path)
204
- fd, concat_list = tempfile.mkstemp(suffix=".txt")
205
- os.close(fd)
206
- with open(concat_list, "w") as f:
207
- for p in tmp_files:
208
- f.write(f"file '{p}'\n")
209
- fd, out_path = tempfile.mkstemp(suffix=".wav")
210
- os.close(fd)
211
- subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
212
- "-f", "concat", "-safe", "0", "-i", concat_list,
213
- "-c", "copy", out_path], check=True, timeout=60)
214
- with open(out_path, "rb") as f:
215
- return f.read()
216
- except Exception:
217
- return b"".join(parts)
218
- finally:
219
- for p in tmp_files:
220
- try: os.unlink(p)
221
- except Exception: pass
222
- for x in (concat_list, out_path):
223
- if x:
224
- try: os.unlink(x)
225
- except Exception: pass
226
-
227
-
228
- def synthesize_piper(text: str, voice_code: str, speed: float = 1.0) -> bytes:
229
- if not PIPER_READY:
230
- raise Exception("Piper is not available on this system")
231
- # Pehle PIPER_VOICES dict mein check karo, nahi mila to dynamic download
232
- if voice_code in PIPER_VOICES:
233
- onnx_path, json_path = download_piper_model(voice_code)
234
- else:
235
- # Dynamic voice — direct HuggingFace se download
236
- onnx_path, json_path = download_piper_dynamic(voice_code)
237
- length_scale = 1.0 / max(0.25, min(4.0, speed))
238
- # Lambi text ko chunk karo (Piper stdin limit + timeout avoid karne ke liye)
239
- if len(text) > 1400:
240
- chunks = split_text(text, max_chars=1400)
241
- else:
242
- chunks = [text]
243
- if len(chunks) == 1:
244
- return _piper_synth_chunk(chunks[0], onnx_path, json_path, length_scale)
245
- parts = []
246
- for ch in chunks:
247
- if ch.strip():
248
- parts.append(_piper_synth_chunk(ch, onnx_path, json_path, length_scale))
249
- if not parts:
250
- raise Exception("Piper: no audio generated")
251
- return _ffmpeg_concat_wav(parts)
252
-
253
-
254
- def split_text(text: str, max_chars: int = 1400) -> list:
255
- """Text ko chunklara bol"""
256
- text = text.strip()
257
- if not text:
258
- return []
259
- sentence_re = re.compile(
260
- r'(?:(?<=[.!?\u0964\u06D4\u061F\u2026])\s+)|(?<=[\u3002\uff01\uff1f])'
261
- )
262
- chunks, current = [], ""
263
- for para in re.split(r'\n+', text):
264
- para = para.strip()
265
- if not para:
266
- if current:
267
- chunks.append(current)
268
- current = ""
269
- continue
270
- for sentence in sentence_re.split(para):
271
- sentence = sentence.strip()
272
- if not sentence:
273
- continue
274
- if len(sentence) > max_chars:
275
- words, buf = sentence.split(), ""
276
- for word in words:
277
- add = (" " if buf else "") + word
278
- if len(buf) + len(add) <= max_chars:
279
- buf += add
280
- else:
281
- if buf:
282
- chunks.append(buf)
283
- buf = word
284
- if buf:
285
- chunks.append(buf)
286
- elif len(current) + len(sentence) + 1 <= max_chars:
287
- current = (current + " " + sentence).strip()
288
- else:
289
- if current:
290
- chunks.append(current)
291
- current = sentence
292
- if current:
293
- chunks.append(current)
294
- current = ""
295
- if current:
296
- chunks.append(current)
297
- return [c for c in chunks if c.strip()]
298
-
299
-
300
- # ══════════════════════════════════════════════════════════════════
301
- # SILERO TTS v4 model (48kHz, 5 Russian speakers)
302
- # Loaded via torch.package.PackageImporter (requires torch < 2.3)
303
- # ══════════════════════════════════════════════════════════════════
304
- SILERO_READY = False
305
- SILERO_MODELS_DIR = "/tmp/silero_models"
306
- SILERO_SAMPLE_RATE = 48000
307
- SILERO_MODELS = {}
308
- SILERO_LOAD_LOCK = threading.Lock()
309
-
310
- SILERO_MODEL_URLS = [
311
- "https://models.silero.ai/models/tts/ru/v4_ru.pt",
312
- "https://huggingface.co/Derur/silero-models/resolve/main/tts/ru/ru_v4/v4_ru.pt",
313
- ]
314
-
315
- SILERO_SPEAKERS_RU = [
316
- "aidar", "baya", "kseniya", "xenia", "eugene",
317
- ]
318
-
319
-
320
- def download_silero_model() -> str:
321
- """Download Silero v4 Russian model. Returns path on success."""
322
- import urllib.request
323
- os.makedirs(SILERO_MODELS_DIR, exist_ok=True)
324
- model_path = os.path.join(SILERO_MODELS_DIR, "v4_ru.pt")
325
- if os.path.exists(model_path) and os.path.getsize(model_path) > 100000:
326
- return model_path
327
- for url in SILERO_MODEL_URLS:
328
- try:
329
- print(f"📥 Downloading Silero v4: {url[:80]}...")
330
- urllib.request.urlretrieve(url, model_path)
331
- if os.path.getsize(model_path) > 100000:
332
- print(f" Silero v4 downloaded ({os.path.getsize(model_path)//1024}KB)")
333
- return model_path
334
- os.remove(model_path)
335
- except Exception as e:
336
- print(f"⚠️ Download failed: {e}")
337
- try:
338
- os.remove(model_path)
339
- except Exception:
340
- pass
341
- return ""
342
-
343
-
344
- def setup_silero():
345
- global SILERO_READY
346
- try:
347
- import torch
348
- model_path = download_silero_model()
349
- if model_path:
350
- model = torch.package.PackageImporter(model_path).load_pickle("tts_models", "model")
351
- SILERO_MODELS["ru"] = model
352
- SILERO_READY = True
353
- print("✅ Silero TTS ready (v4 Russian — 5 speakers)")
354
- else:
355
- print("❌ Silero TTS: model download failed")
356
- except Exception as e:
357
- print(f"❌ Silero setup failed: {e}")
358
-
359
- threading.Thread(target=setup_silero, daemon=True).start()
360
-
361
-
362
- def synthesize_silero(text: str, voice_code: str) -> bytes:
363
- """Silero TTS — code: silero:ru_xenia. v4 model via torch.package.
364
- Model lazily load hota hai (self-heal) agar startup thread fail hua ho."""
365
- import numpy as np, scipy.io.wavfile as wav
366
- try:
367
- import torch
368
- except ImportError:
369
- raise Exception("Silero TTS requires torch. Install: pip install torch==2.1.2")
370
-
371
- lang_speaker = voice_code.replace("silero:", "")
372
- lang = lang_speaker.split("_")[0]
373
- speaker = lang_speaker.split("_", 1)[1] if "_" in lang_speaker else lang_speaker
374
-
375
- # Validate speaker — only real v4 speakers allowed
376
- valid_speakers = set(SILERO_SPEAKERS_RU)
377
- if speaker not in valid_speakers:
378
- raise Exception(f"Invalid Silero speaker '{speaker}'. Valid: {', '.join(valid_speakers)}")
379
-
380
- # Model on-demand load (self-heals if startup thread failed / was slow)
381
- if lang not in SILERO_MODELS:
382
- with SILERO_LOAD_LOCK:
383
- if lang not in SILERO_MODELS:
384
- model_path = download_silero_model()
385
- if not model_path:
386
- raise Exception("Silero v4 model could not be downloaded (check network / model URL).")
387
- model = torch.package.PackageImporter(model_path).load_pickle("tts_models", "model")
388
- SILERO_MODELS[lang] = model
389
- global SILERO_READY
390
- SILERO_READY = True
391
-
392
- model = SILERO_MODELS[lang]
393
- audio = model.apply_tts(text=text, speaker=speaker, sample_rate=SILERO_SAMPLE_RATE)
394
- audio_np = audio.numpy() if hasattr(audio, "numpy") else audio.cpu().detach().numpy()
395
-
396
- buf = io.BytesIO()
397
- wav.write(buf, SILERO_SAMPLE_RATE, (audio_np * 32767).astype(np.int16))
398
- buf.seek(0)
399
- return buf.read()
400
-
401
-
402
- def _ffmpeg_concat_mp3(parts: list) -> bytes:
403
- """Merge multiple MP3 byte chunks using ffmpeg concat."""
404
- if len(parts) == 1:
405
- return parts[0]
406
- tmp_files = []
407
- concat_list = None
408
- out_path = None
409
- try:
410
- for data in parts:
411
- fd, path = tempfile.mkstemp(suffix=".mp3")
412
- os.close(fd)
413
- with open(path, "wb") as f:
414
- f.write(data)
415
- tmp_files.append(path)
416
- fd, concat_list = tempfile.mkstemp(suffix=".txt")
417
- os.close(fd)
418
- with open(concat_list, "w") as f:
419
- for p in tmp_files:
420
- f.write(f"file '{p}'\n")
421
- fd, out_path = tempfile.mkstemp(suffix=".mp3")
422
- os.close(fd)
423
- subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
424
- "-f", "concat", "-safe", "0", "-i", concat_list,
425
- "-c", "copy", out_path], check=True, timeout=60)
426
- with open(out_path, "rb") as f:
427
- return f.read()
428
- except Exception:
429
- return b"".join(parts)
430
- finally:
431
- for p in tmp_files:
432
- try: os.unlink(p)
433
- except Exception: pass
434
- if concat_list:
435
- try: os.unlink(concat_list)
436
- except Exception: pass
437
- if out_path:
438
- try: os.unlink(out_path)
439
- except Exception: pass
440
-
441
-
442
- async def _edge_synth_chunk(chunk: str, voice: str, kwargs: dict) -> bytes:
443
- """One chunk ki audio lao, 3 baar retry karo. Fail par b"" return."""
444
- import edge_tts
445
- for attempt in range(3):
446
- data = bytearray()
447
- try:
448
- comm = edge_tts.Communicate(chunk, voice, **kwargs)
449
- async for packet in comm.stream():
450
- if packet["type"] == "audio" and packet.get("data"):
451
- data.extend(packet["data"])
452
- if data:
453
- return bytes(data)
454
- except Exception:
455
- if attempt == 2:
456
- return b""
457
- await asyncio.sleep(1 + attempt)
458
- return b""
459
-
460
-
461
- async def synthesize_edge(
462
- text: str,
463
- voice: str,
464
- rate: str = "+0%",
465
- volume: str = "+0%",
466
- pitch: str = "+0Hz",
467
- style: str = None,
468
- styledegree: str = None,
469
- ) -> bytes:
470
- import edge_tts
471
- chunks = split_text(text)
472
- audio_parts = []
473
- kwargs = {"rate": rate, "volume": volume, "pitch": pitch}
474
- if style and style != "Default" and style != "General":
475
- kwargs["style"] = style
476
- if styledegree is not None:
477
- try:
478
- sd = float(styledegree)
479
- if 0.0 <= sd <= 2.0:
480
- kwargs["styledegree"] = styledegree
481
- except Exception:
482
- pass
483
- # Style drop karne wala safe set (non-EN voices kuch styles reject karti hain)
484
- safe_kwargs = {k: v for k, v in kwargs.items() if k not in ("style", "styledegree")}
485
- for chunk in chunks:
486
- audio = await _edge_synth_chunk(chunk, voice, kwargs)
487
- # Agar style ki wajah se fail hua ho, style hata kar dobara try karo
488
- if not audio and kwargs.get("style"):
489
- audio = await _edge_synth_chunk(chunk, voice, safe_kwargs)
490
- audio_parts.append(audio if audio else b"")
491
- if len(audio_parts) == 1:
492
- return audio_parts[0]
493
- return _ffmpeg_concat_mp3(audio_parts)
494
-
495
-
496
- @app.get("/")
497
- def root():
498
- return {"status": "VoiceCraft TTS Server OK", "engines": ["edge", "piper", "silero"]}
499
-
500
-
501
- @app.get("/health")
502
- @app.head("/health")
503
- def health():
504
- return {
505
- "status": "ok",
506
- "piper_ready": PIPER_READY,
507
- "silero_ready": SILERO_READY,
508
- "silero_speakers": SILERO_SPEAKERS_RU,
509
- "engines": ["edge", "piper", "silero"],
510
- }
511
-
512
-
513
- @app.get("/all_voices")
514
- async def all_voices_list():
515
- """All voices — Edge (400+) + Piper (900+) + Silero (5 RU)"""
516
- result = {"edge": {}, "piper": {}, "silero": {}}
517
-
518
- # Edge TTS — 400+ voices (complete list, clean naming)
519
- try:
520
- import edge_tts
521
- voices = await edge_tts.list_voices()
522
- for v in voices:
523
- short = v.get("ShortName", "")
524
- friendly = v.get("FriendlyName", "") or short
525
- name = friendly
526
- for remove in ["Microsoft Server Speech Text to Speech Voice", "Microsoft", "Online", "(Natural)", "(Neural)", "(Standard)", "(Multilingual)", "(Expressive)"]:
527
- name = name.replace(remove, "")
528
- if "," in name:
529
- name = name.split(",")[-1]
530
- # Clean dash pattern: " - " or " - " → single " - "
531
- name = re.sub(r'\s*-\s*', ' - ', name)
532
- # Collapse all whitespace to single space
533
- name = re.sub(r'\s+', ' ', name)
534
- name = name.strip(" -").strip()
535
- region = short.split("-")[0] + "-" + short.split("-")[1] if "-" in short else ""
536
- result["edge"][f"{name} [{region}]"] = short
537
- except Exception as e:
538
- print(f"Edge voices error: {e}")
539
-
540
- # Piper TTS — all voices (clean naming, no engine hints)
541
- try:
542
- import urllib.request, json as _json
543
- api_url = "https://huggingface.co/api/models/rhasspy/piper-voices"
544
- req = urllib.request.Request(api_url, headers={"User-Agent": "VoiceCraft/2.0"})
545
- with urllib.request.urlopen(req, timeout=60) as resp:
546
- data = _json.loads(resp.read())
547
- siblings = data.get("siblings", [])
548
- for s in siblings:
549
- rfn = s.get("rfilename", s.get("rfn", ""))
550
- if not rfn.endswith(".onnx") or ".json" in rfn or "samples" in rfn:
551
- continue
552
- parts = rfn.split("/")
553
- if len(parts) < 2:
554
- continue
555
- model_name = parts[-1].replace(".onnx", "")
556
- dash_parts = model_name.rsplit("-", 2)
557
- if len(dash_parts) >= 3:
558
- lang_code = dash_parts[0].replace("_", "-")
559
- voice = dash_parts[1].replace("_", " ").title()
560
- quality = dash_parts[2]
561
- if quality in ("low", "x_low"):
562
- continue
563
- quality_map = {"high": " +", "medium": "", "low": " -", "x_low": " --"}
564
- qs = quality_map.get(quality, " -")
565
- display = f"{voice} [{lang_code}]{qs}"
566
- else:
567
- display = model_name.replace("_", " ").title()
568
- full_code = f"piper:{model_name}"
569
- result["piper"][display] = full_code
570
- except Exception as e:
571
- print(f"Piper dynamic fetch error: {e}")
572
- for key in PIPER_VOICES:
573
- result["piper"][key] = key # voice code as string, not tuple
574
-
575
- # Silero v4 Russian (official v4_ru speakers)
576
- silero_ru_speakers = {
577
- "aidar": "Aidar", "baya": "Baya", "kseniya": "Kseniya",
578
- "xenia": "Xenia", "eugene": "Eugene",
579
- }
580
- for speaker_code, display_name in silero_ru_speakers.items():
581
- result["silero"][f"{display_name} \u2022 Russian [RU]"] = f"silero:ru_{speaker_code}"
582
-
583
- total = len(result["edge"]) + len(result["piper"]) + len(result.get("silero", {}))
584
- return {"voices": result, "total": total}
585
-
586
-
587
- @app.post("/tts")
588
- async def tts_endpoint(
589
- request: Request,
590
- engine: str = Form(...), # "edge" | "piper" | "silero"
591
- text: str = Form(...),
592
- voice: str = Form("en-US-AvaNeural"), # edge voice code OR piper/silero code
593
- rate: str = Form("+0%"), # edge only
594
- volume: str = Form("+0%"), # edge only
595
- pitch: str = Form("+0Hz"), # edge only
596
- speed: float = Form(1.0), # piper only
597
- style: str = Form(None), # edge style (emotion)
598
- styledegree: str = Form(None), # edge style degree 0-2
599
- ):
600
- verify_token(request)
601
-
602
- if not text or not text.strip():
603
- return JSONResponse(status_code=400, content={"error": "Text is empty"})
604
-
605
- text = text.strip()
606
-
607
- # Abuse / timeout guard — lambi text Chapter Mode se bhejo
608
- if len(text) > 60000:
609
- return JSONResponse(
610
- status_code=413,
611
- content={"error": "Text too long (max 60000 chars). Use Chapter Mode for longer text."},
612
- )
613
-
614
- try:
615
- if engine == "edge":
616
- audio = await synthesize_edge(text, voice, rate=rate, volume=volume, pitch=pitch, style=style, styledegree=styledegree)
617
- media = "audio/mpeg"
618
- fname = "tts_edge.mp3"
619
-
620
- elif engine == "piper":
621
- # Sync + subprocess/torch → run off the event loop so the server
622
- # stays responsive and doesn't time out under load.
623
- audio = await asyncio.to_thread(synthesize_piper, text, voice, speed)
624
- media = "audio/wav"
625
- fname = "tts_piper.wav"
626
-
627
- elif engine == "silero":
628
- audio = await asyncio.to_thread(synthesize_silero, text, voice)
629
- media = "audio/wav"
630
- fname = "tts_silero.wav"
631
-
632
- else:
633
- return JSONResponse(status_code=400, content={"error": f"Unknown engine: {engine}"})
634
-
635
- return StreamingResponse(
636
- io.BytesIO(audio),
637
- media_type=media,
638
- headers={"Content-Disposition": f"attachment; filename={fname}"},
639
- )
640
-
641
- except Exception as e:
642
- logging.error(f"TTS error: {e}", exc_info=True)
643
- return JSONResponse(
644
- status_code=500,
645
- content={"error": f"Synthesis failed: {str(e)[:240]}"},
646
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, io, asyncio, tempfile, threading, re, subprocess, shutil, logging, secrets, sys, platform, math, hashlib, time
2
+
3
+ try:
4
+ import spaces
5
+ except ImportError:
6
+ class _SpacesFallback:
7
+ """Keep the shared backend importable outside Hugging Face Spaces."""
8
+
9
+ @staticmethod
10
+ def GPU(function=None, **_kwargs):
11
+ def decorator(fn):
12
+ return fn
13
+
14
+ return decorator(function) if callable(function) else decorator
15
+
16
+ spaces = _SpacesFallback()
17
+
18
+ import gradio as gr
19
+ import requests
20
+
21
+ # Hide console window on Windows
22
+ CREATE_NO_WINDOW = 0x08000000 if sys.platform == "win32" else 0
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 (
41
+ os.environ.get("HF_TOKEN")
42
+ or os.environ.get("HUGGINGFACE_HUB_TOKEN")
43
+ or os.environ.get("HUGGING_FACE_HUB_TOKEN")
44
+ ):
45
+ os.environ["HF_TOKEN"] = _RAW_API_SECRET
46
+
47
+ API_SECRET = (
48
+ os.environ.get("VOICECRAFT_API_SECRET", "").strip()
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
59
+ )
60
+ AUTH_MODE = os.environ.get("VOICECRAFT_AUTH_MODE", "license").strip().lower()
61
+ 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):
73
+ raw = os.environ.get(name)
74
+ if raw is None or raw == "":
75
+ return default
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):
92
+ if not API_SECRET:
93
+ logging.warning("⚠️ API_SECRET not set — rejecting request")
94
+ raise HTTPException(status_code=503, detail="API_SECRET missing in Hugging Face Space secrets")
95
+ token = request.headers.get("X-API-Token", "").strip()
96
+ if not secrets.compare_digest(token, API_SECRET):
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(
185
+ "CLONE_REQUESTS_PER_MINUTE" if is_clone else "TTS_REQUESTS_PER_MINUTE",
186
+ 8 if is_clone else 60,
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
194
+ for timestamp in _LICENSE_RATE_STATE.get(state_key, [])
195
+ if now - timestamp < window
196
+ ]
197
+ if len(requests_in_window) >= limit:
198
+ raise HTTPException(status_code=429, detail="Request limit reached; try again shortly")
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
226
+ # ══════════════════════════════════════════════════════════════════
227
+ PIPER_DIR = "/tmp/piper"
228
+ PIPER_BIN = os.path.join(PIPER_DIR, "piper")
229
+ PIPER_MODELS_DIR = "/tmp/piper_models"
230
+ PIPER_READY = False
231
+
232
+ PIPER_VOICES = {
233
+ # English
234
+ "piper:en_US-amy-medium": ("en_US-amy-medium.onnx", "en_US-amy-medium.onnx.json"),
235
+ "piper:en_US-joe-medium": ("en_US-joe-medium.onnx", "en_US-joe-medium.onnx.json"),
236
+ "piper:en_US-lessac-medium": ("en_US-lessac-medium.onnx", "en_US-lessac-medium.onnx.json"),
237
+ "piper:en_US-ryan-high": ("en_US-ryan-high.onnx", "en_US-ryan-high.onnx.json"),
238
+ "piper:en_GB-alan-medium": ("en_GB-alan-medium.onnx", "en_GB-alan-medium.onnx.json"),
239
+ "piper:en_GB-alba-medium": ("en_GB-alba-medium.onnx", "en_GB-alba-medium.onnx.json"),
240
+ # Urdu / Hindi / Arabic
241
+ "piper:ur_PK-fasih-medium": ("ur_PK-fasih-medium.onnx", "ur_PK-fasih-medium.onnx.json"),
242
+ "piper:hi_IN-pratham-medium": ("hi_IN-pratham-medium.onnx", "hi_IN-pratham-medium.onnx.json"),
243
+ "piper:ar_JO-kareem-medium": ("ar_JO-kareem-medium.onnx", "ar_JO-kareem-medium.onnx.json"),
244
+ # Other languages
245
+ "piper:de_DE-thorsten-medium": ("de_DE-thorsten-medium.onnx", "de_DE-thorsten-medium.onnx.json"),
246
+ "piper:fr_FR-upmc-medium": ("fr_FR-upmc-medium.onnx", "fr_FR-upmc-medium.onnx.json"),
247
+ "piper:ru_RU-irina-medium": ("ru_RU-irina-medium.onnx", "ru_RU-irina-medium.onnx.json"),
248
+ "piper:tr_TR-dfki-medium": ("tr_TR-dfki-medium.onnx", "tr_TR-dfki-medium.onnx.json"),
249
+ "piper:pt_BR-faber-medium": ("pt_BR-faber-medium.onnx", "pt_BR-faber-medium.onnx.json"),
250
+ "piper:nl_NL-mls-medium": ("nl_NL-mls-medium.onnx", "nl_NL-mls-medium.onnx.json"),
251
+ }
252
+
253
+ PIPER_BASE_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main"
254
+
255
+ def setup_piper():
256
+ global PIPER_READY
257
+ try:
258
+ import platform
259
+ os.makedirs(PIPER_DIR, exist_ok=True)
260
+ os.makedirs(PIPER_MODELS_DIR, exist_ok=True)
261
+
262
+ system = platform.system().lower()
263
+ arch = platform.machine().lower()
264
+
265
+ if system == "linux" and "x86" in arch:
266
+ piper_url = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz"
267
+ elif system == "linux" and "aarch" in arch:
268
+ piper_url = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_aarch64.tar.gz"
269
+ else:
270
+ print(f"⚠️ Piper: unsupported platform {system}/{arch}, Piper disabled")
271
+ return
272
+
273
+ if not os.path.exists(PIPER_BIN):
274
+ print("📥 Piper binary indiriliyor...")
275
+ import urllib.request
276
+ tar_path = "/tmp/piper.tar.gz"
277
+ urllib.request.urlretrieve(piper_url, tar_path)
278
+ import tarfile
279
+ with tarfile.open(tar_path, "r:gz") as tf:
280
+ tf.extractall("/tmp/piper_extract")
281
+ extracted = "/tmp/piper_extract/piper"
282
+ if os.path.isdir(extracted):
283
+ for item in os.listdir(extracted):
284
+ shutil.move(os.path.join(extracted, item), os.path.join(PIPER_DIR, item))
285
+ else:
286
+ shutil.move(extracted, PIPER_BIN)
287
+ os.chmod(PIPER_BIN, 0o755)
288
+ print("✅ Piper binary ready")
289
+
290
+ PIPER_READY = True
291
+ print("✅ Piper TTS ready")
292
+ except Exception as e:
293
+ print(f"⚠️ Piper setup failed (non-critical): {e}")
294
+ PIPER_READY = False
295
+
296
+ threading.Thread(target=setup_piper, daemon=True).start()
297
+
298
+
299
+ def download_piper_model(voice_code: str) -> tuple:
300
+ """Model yoksa indir, path tuple dondur (onnx, json)"""
301
+ if voice_code not in PIPER_VOICES:
302
+ raise ValueError(f"Unknown Piper voice: {voice_code}")
303
+ onnx_file, json_file = PIPER_VOICES[voice_code]
304
+ onnx_path = os.path.join(PIPER_MODELS_DIR, onnx_file)
305
+ json_path = os.path.join(PIPER_MODELS_DIR, json_file)
306
+
307
+ import urllib.request
308
+ # Build correct HF path: en/en_US/amy/medium/en_US-amy-medium.onnx
309
+ parts = onnx_file.rsplit("-", 2)
310
+ lang_code = parts[0] # en_US
311
+ voice = parts[1] # amy
312
+ quality = parts[2].replace(".onnx", "") # medium
313
+ lang_short = lang_code.split("_")[0] # en
314
+ hf_dir = f"{lang_short}/{lang_code}/{voice}/{quality}"
315
+
316
+ for fname, fpath in [(onnx_file, onnx_path), (json_file, json_path)]:
317
+ if not os.path.exists(fpath):
318
+ url = f"{PIPER_BASE_URL}/{hf_dir}/{fname}"
319
+ print(f"📥 Downloading Piper model: {fname}")
320
+ try:
321
+ urllib.request.urlretrieve(url, fpath)
322
+ except Exception:
323
+ url2 = f"{PIPER_BASE_URL}/{lang_short}/{lang_code}/{fname}"
324
+ urllib.request.urlretrieve(url2, fpath)
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:
371
+ cmd = [
372
+ PIPER_BIN,
373
+ "--model", onnx_path,
374
+ "--config", json_path,
375
+ "--output_file", out_path,
376
+ "--length_scale", str(round(length_scale, 2)),
377
+ ]
378
+ result = subprocess.run(
379
+ cmd,
380
+ input=text.encode("utf-8"),
381
+ capture_output=True,
382
+ timeout=120,
383
+ creationflags=CREATE_NO_WINDOW,
384
+ )
385
+ if result.returncode != 0:
386
+ raise Exception(f"Piper error: {result.stderr.decode()[:200]}")
387
+ with open(out_path, "rb") as f:
388
+ return f.read()
389
+ finally:
390
+ if os.path.exists(out_path):
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:
442
+ chunks = split_text(text, max_chars=1400)
443
+ else:
444
+ chunks = [text]
445
+ if len(chunks) == 1:
446
+ return _piper_synth_chunk(chunks[0], onnx_path, json_path, length_scale)
447
+ parts = []
448
+ for ch in chunks:
449
+ if ch.strip():
450
+ parts.append(_piper_synth_chunk(ch, onnx_path, json_path, length_scale))
451
+ if not parts:
452
+ raise Exception("Piper: no audio generated")
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):
466
+ para = para.strip()
467
+ if not para:
468
+ if current:
469
+ chunks.append(current)
470
+ current = ""
471
+ continue
472
+ for sentence in sentence_re.split(para):
473
+ sentence = sentence.strip()
474
+ if not sentence:
475
+ continue
476
+ if len(sentence) > max_chars:
477
+ words, buf = sentence.split(), ""
478
+ for word in words:
479
+ add = (" " if buf else "") + word
480
+ if len(buf) + len(add) <= max_chars:
481
+ buf += add
482
+ else:
483
+ if buf:
484
+ chunks.append(buf)
485
+ buf = word
486
+ if buf:
487
+ chunks.append(buf)
488
+ elif len(current) + len(sentence) + 1 <= max_chars:
489
+ current = (current + " " + sentence).strip()
490
+ else:
491
+ if current:
492
+ chunks.append(current)
493
+ current = sentence
494
+ if current:
495
+ chunks.append(current)
496
+ current = ""
497
+ if current:
498
+ chunks.append(current)
499
+ return [c for c in chunks if c.strip()]
500
+
501
+
502
+ # ══════════════════════════════════════════════════════════════════
503
+ # SILERO TTS — v4 model (48kHz, 5 Russian speakers)
504
+ # Loaded via torch.package.PackageImporter (requires torch < 2.3)
505
+ # ══════════════════════════════════════════════════════════════════
506
+ SILERO_READY = False
507
+ SILERO_MODELS_DIR = "/tmp/silero_models"
508
+ SILERO_SAMPLE_RATE = 48000
509
+ SILERO_MODELS = {}
510
+ SILERO_LOAD_LOCK = threading.Lock()
511
+
512
+ SILERO_MODEL_URLS = [
513
+ "https://models.silero.ai/models/tts/ru/v4_ru.pt",
514
+ "https://huggingface.co/Derur/silero-models/resolve/main/tts/ru/ru_v4/v4_ru.pt",
515
+ ]
516
+
517
+ SILERO_SPEAKERS_RU = [
518
+ "aidar", "baya", "kseniya", "xenia", "eugene",
519
+ ]
520
+
521
+
522
+ def download_silero_model() -> str:
523
+ """Download Silero v4 Russian model. Returns path on success."""
524
+ import urllib.request
525
+ os.makedirs(SILERO_MODELS_DIR, exist_ok=True)
526
+ model_path = os.path.join(SILERO_MODELS_DIR, "v4_ru.pt")
527
+ if os.path.exists(model_path) and os.path.getsize(model_path) > 100000:
528
+ return model_path
529
+ for url in SILERO_MODEL_URLS:
530
+ try:
531
+ print(f"📥 Downloading Silero v4: {url[:80]}...")
532
+ urllib.request.urlretrieve(url, model_path)
533
+ if os.path.getsize(model_path) > 100000:
534
+ print(f"✅ Silero v4 downloaded ({os.path.getsize(model_path)//1024}KB)")
535
+ return model_path
536
+ os.remove(model_path)
537
+ except Exception as e:
538
+ print(f"⚠️ Download failed: {e}")
539
+ try:
540
+ os.remove(model_path)
541
+ except Exception:
542
+ pass
543
+ return ""
544
+
545
+
546
+ def setup_silero():
547
+ global SILERO_READY
548
+ try:
549
+ import torch
550
+ model_path = download_silero_model()
551
+ if model_path:
552
+ model = torch.package.PackageImporter(model_path).load_pickle("tts_models", "model")
553
+ SILERO_MODELS["ru"] = model
554
+ SILERO_READY = True
555
+ print("✅ Silero TTS ready (v4 Russian — 5 speakers)")
556
+ else:
557
+ print("❌ Silero TTS: model download failed")
558
+ except Exception as e:
559
+ print(f"❌ Silero setup failed: {e}")
560
+
561
+ threading.Thread(target=setup_silero, daemon=True).start()
562
+
563
+
564
+ def synthesize_silero(text: str, voice_code: str) -> bytes:
565
+ """Silero TTS — code: silero:ru_xenia. v4 model via torch.package.
566
+ Model lazily load hota hai (self-heal) agar startup thread fail hua ho."""
567
+ import numpy as np, scipy.io.wavfile as wav
568
+ try:
569
+ import torch
570
+ except ImportError:
571
+ raise Exception("Silero TTS requires torch.")
572
+
573
+ lang_speaker = voice_code.replace("silero:", "")
574
+ lang = lang_speaker.split("_")[0]
575
+ speaker = lang_speaker.split("_", 1)[1] if "_" in lang_speaker else lang_speaker
576
+
577
+ # Validate speaker — only real v4 speakers allowed
578
+ valid_speakers = set(SILERO_SPEAKERS_RU)
579
+ if speaker not in valid_speakers:
580
+ raise Exception(f"Invalid Silero speaker '{speaker}'. Valid: {', '.join(valid_speakers)}")
581
+
582
+ # Model on-demand load (self-heals if startup thread failed / was slow)
583
+ if lang not in SILERO_MODELS:
584
+ with SILERO_LOAD_LOCK:
585
+ if lang not in SILERO_MODELS:
586
+ model_path = download_silero_model()
587
+ if not model_path:
588
+ raise Exception("Silero v4 model could not be downloaded (check network / model URL).")
589
+ model = torch.package.PackageImporter(model_path).load_pickle("tts_models", "model")
590
+ SILERO_MODELS[lang] = model
591
+ global SILERO_READY
592
+ SILERO_READY = True
593
+
594
+ model = SILERO_MODELS[lang]
595
+ audio = model.apply_tts(text=text, speaker=speaker, sample_rate=SILERO_SAMPLE_RATE)
596
+ audio_np = audio.numpy() if hasattr(audio, "numpy") else audio.cpu().detach().numpy()
597
+
598
+ buf = io.BytesIO()
599
+ wav.write(buf, SILERO_SAMPLE_RATE, (audio_np * 32767).astype(np.int16))
600
+ buf.seek(0)
601
+ return buf.read()
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:
650
+ comm = edge_tts.Communicate(chunk, voice, **kwargs)
651
+ async for packet in comm.stream():
652
+ if packet["type"] == "audio" and packet.get("data"):
653
+ data.extend(packet["data"])
654
+ if data:
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
+
663
+ async def synthesize_edge(
664
+ text: str,
665
+ voice: str,
666
+ rate: str = "+0%",
667
+ volume: str = "+0%",
668
+ pitch: str = "+0Hz",
669
+ style: str = None,
670
+ styledegree: str = None,
671
+ ) -> bytes:
672
+ import edge_tts
673
+ chunks = split_text(text)
674
+ audio_parts = []
675
+ kwargs = {"rate": rate, "volume": volume, "pitch": pitch}
676
+ if style and style != "Default" and style != "General":
677
+ kwargs["style"] = style
678
+ if styledegree is not None:
679
+ try:
680
+ sd = float(styledegree)
681
+ if 0.0 <= sd <= 2.0:
682
+ kwargs["styledegree"] = styledegree
683
+ except Exception:
684
+ pass
685
+ # Style drop karne wala safe set (non-EN voices kuch styles reject karti hain)
686
+ safe_kwargs = {k: v for k, v in kwargs.items() if k not in ("style", "styledegree")}
687
+ for chunk in chunks:
688
+ audio = await _edge_synth_chunk(chunk, voice, kwargs)
689
+ # Agar style ki wajah se fail hua ho, style hata kar dobara try karo
690
+ if not audio and kwargs.get("style"):
691
+ audio = await _edge_synth_chunk(chunk, voice, safe_kwargs)
692
+ audio_parts.append(audio if audio else b"")
693
+ if len(audio_parts) == 1:
694
+ return audio_parts[0]
695
+ return _ffmpeg_concat_mp3(audio_parts)
696
+ # ═════════════════════════════════════════════════════════════════════════
697
+ # VOICE CLONING ENGINES - FREE MODELS (Task 4/5)
698
+ # ═════════════════════════════════════════════════════════════════════════
699
+
700
+ 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 = []
710
+ _POCKET_RESULT_CACHE_LOCK = threading.Lock()
711
+ _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:
720
+ return bool(
721
+ os.environ.get("HF_TOKEN")
722
+ or os.environ.get("HUGGINGFACE_HUB_TOKEN")
723
+ or os.environ.get("HUGGING_FACE_HUB_TOKEN")
724
+ )
725
+
726
+
727
+ def _pocket_clone_auth_error() -> str:
728
+ return (
729
+ "Voice cloning weights are gated. Accept the Hugging Face model terms, "
730
+ "add a read token as HF_TOKEN in Space secrets, then restart/rebuild the Space."
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")
751
+ else:
752
+ print("Pocket TTS loaded without voice cloning weights. HF_TOKEN/model access required.")
753
+ except Exception as exc:
754
+ _POCKET_MODEL_ERROR = str(exc)
755
+ logging.exception("Pocket TTS startup failed")
756
+ raise RuntimeError(_POCKET_MODEL_ERROR or "Voice clone model is not ready")
757
+ return _POCKET_MODEL
758
+
759
+
760
+ def _warm_pocket_model():
761
+ if clone_engines_enabled():
762
+ try:
763
+ _load_pocket_model()
764
+ except Exception:
765
+ pass
766
+
767
+
768
+ threading.Thread(target=_warm_pocket_model, daemon=True).start()
769
+
770
+
771
+ def _reference_audio_key(reference_path: str) -> str:
772
+ digest = hashlib.sha256()
773
+ with open(reference_path, "rb") as reference_file:
774
+ for chunk in iter(lambda: reference_file.read(1024 * 1024), b""):
775
+ digest.update(chunk)
776
+ return digest.hexdigest()
777
+
778
+
779
+ def _normalize_clone_reference(reference_path: str) -> str:
780
+ """Decode any accepted upload into a mono PCM WAV Pocket TTS can read."""
781
+ if not reference_path or not os.path.exists(reference_path):
782
+ raise ValueError("A reference audio file is required for voice cloning")
783
+ ffmpeg = shutil.which("ffmpeg")
784
+ if not ffmpeg:
785
+ raise RuntimeError("ffmpeg is required to decode voice clone reference audio")
786
+ fd, normalized_path = tempfile.mkstemp(suffix=".wav")
787
+ os.close(fd)
788
+ try:
789
+ result = subprocess.run(
790
+ [
791
+ ffmpeg,
792
+ "-y",
793
+ "-hide_banner",
794
+ "-loglevel",
795
+ "error",
796
+ "-i",
797
+ reference_path,
798
+ "-map",
799
+ "0:a:0",
800
+ "-t",
801
+ "12",
802
+ "-vn",
803
+ "-sn",
804
+ "-dn",
805
+ "-acodec",
806
+ "pcm_s16le",
807
+ "-ac",
808
+ "1",
809
+ "-ar",
810
+ "24000",
811
+ "-f",
812
+ "wav",
813
+ normalized_path,
814
+ ],
815
+ capture_output=True,
816
+ timeout=60,
817
+ creationflags=CREATE_NO_WINDOW,
818
+ )
819
+ if result.returncode != 0 or os.path.getsize(normalized_path) <= 44:
820
+ detail = result.stderr.decode("utf-8", errors="replace").strip()
821
+ raise ValueError(
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:
828
+ os.unlink(normalized_path)
829
+ except OSError:
830
+ pass
831
+ raise
832
+
833
+
834
+ def _clone_request_key(text: str, reference_key: str) -> str:
835
+ payload = f"{reference_key}\0{text or ''}".encode("utf-8")
836
+ return hashlib.sha256(payload).hexdigest()
837
+
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):
845
+ if len(audio) > _POCKET_RESULT_CACHE_MAX_BYTES:
846
+ return
847
+ with _POCKET_RESULT_CACHE_LOCK:
848
+ if cache_key in _POCKET_RESULT_CACHE:
849
+ _POCKET_RESULT_CACHE_ORDER.remove(cache_key)
850
+ _POCKET_RESULT_CACHE[cache_key] = audio
851
+ _POCKET_RESULT_CACHE_ORDER.append(cache_key)
852
+ while len(_POCKET_RESULT_CACHE_ORDER) > _POCKET_RESULT_CACHE_LIMIT:
853
+ oldest = _POCKET_RESULT_CACHE_ORDER.pop(0)
854
+ _POCKET_RESULT_CACHE.pop(oldest, None)
855
+
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)
866
+ _POCKET_VOICE_STATE_CACHE[reference_key] = 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()
878
+ if not getattr(model, "has_voice_cloning", False):
879
+ raise RuntimeError(_pocket_clone_auth_error())
880
+ import numpy as np
881
+
882
+ voice_state = _get_cached_voice_state(reference_key)
883
+ audio_parts = []
884
+ with _POCKET_INFER_LOCK:
885
+ if voice_state is None:
886
+ try:
887
+ voice_state = model.get_state_for_audio_prompt(reference_path)
888
+ except Exception as exc:
889
+ message = str(exc)
890
+ if "could not download the weights" in message.lower() or "voice cloning" in message.lower():
891
+ raise RuntimeError(_pocket_clone_auth_error()) from exc
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:
905
+ audio_np = audio_parts[0]
906
+ else:
907
+ silence = np.zeros(int(model.sample_rate * 0.12), dtype=audio_parts[0].dtype)
908
+ joined = []
909
+ for index, part in enumerate(audio_parts):
910
+ if index:
911
+ joined.append(silence)
912
+ joined.append(part)
913
+ audio_np = np.concatenate(joined)
914
+ import scipy.io.wavfile as wav
915
+ if audio_np.size == 0:
916
+ raise RuntimeError("Voice clone model did not produce audio")
917
+ buf = io.BytesIO()
918
+ wav.write(buf, int(model.sample_rate), audio_np)
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
977
+ volume: str = Form("+0%"), # edge only
978
+ pitch: str = Form("+0Hz"), # edge only
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:
992
+ return JSONResponse(
993
+ status_code=413,
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,
1008
+ content={
1009
+ "error": (
1010
+ "Clone text is too long "
1011
+ f"(max {MAX_CLONE_CHARACTERS} characters per request)"
1012
+ )
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"})
1022
+ fd_ref, reference_path = tempfile.mkstemp(suffix=suffix)
1023
+ os.close(fd_ref)
1024
+ data = await reference_audio.read(25 * 1024 * 1024 + 1)
1025
+ if not data:
1026
+ return JSONResponse(status_code=400, content={"error": "Reference audio is empty"})
1027
+ if len(data) > 25 * 1024 * 1024:
1028
+ return JSONResponse(status_code=413, content={"error": "Reference audio is too large (max 25 MB)"})
1029
+ with open(reference_path, "wb") as f:
1030
+ f.write(data)
1031
+ voice_cloning = True
1032
+
1033
+ if engine == "edge":
1034
+ audio = await synthesize_edge(text, voice, rate=rate, volume=volume, pitch=pitch, style=style, styledegree=styledegree)
1035
+ media = "audio/mpeg"
1036
+ fname = "tts_edge.mp3"
1037
+
1038
+ elif engine == "piper":
1039
+ # Sync + subprocess/torch → run off the event loop so the server
1040
+ # stays responsive and doesn't time out under load.
1041
+ audio = await asyncio.to_thread(synthesize_piper, text, voice, speed)
1042
+ media = "audio/wav"
1043
+ fname = "tts_piper.wav"
1044
+
1045
+ elif engine == "silero":
1046
+ audio = await asyncio.to_thread(synthesize_silero, text, voice)
1047
+ media = "audio/wav"
1048
+ fname = "tts_silero.wav"
1049
+
1050
+ elif engine == "f5tts":
1051
+ audio = await synthesize_f5tts(text, reference_path=reference_path)
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}"},
1067
+ )
1068
+
1069
+ except Exception as e:
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()
1123
+ for v in voices:
1124
+ short = v.get("ShortName", "")
1125
+ friendly = v.get("FriendlyName", "") or short
1126
+ name = friendly
1127
+ for remove in ["Microsoft Server Speech Text to Speech Voice", "Microsoft", "Online", "(Natural)", "(Neural)", "(Standard)", "(Multilingual)", "(Expressive)"]:
1128
+ name = name.replace(remove, "")
1129
+ if "," in name:
1130
+ name = name.split(",")[-1]
1131
+ # Clean dash pattern: " - " or " - " → single " - "
1132
+ name = re.sub(r'\s*-\s*', ' - ', name)
1133
+ # Collapse all whitespace to single space
1134
+ name = re.sub(r'\s+', ' ', name)
1135
+ name = name.strip(" -").strip()
1136
+ region = short.split("-")[0] + "-" + short.split("-")[1] if "-" in short else ""
1137
+ result["edge"][f"{name} [{region}]"] = short
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 = {
1178
+ "aidar": "Aidar", "baya": "Baya", "kseniya": "Kseniya",
1179
+ "xenia": "Xenia", "eugene": "Eugene",
1180
+ }
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())
1198
+ return {"voices": result, "total": total, "clone_enabled": True}
1199
+
1200
+
1201
+ 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
+
1209
+
1210
+ def _write_audio_file(audio: bytes, suffix: str) -> str:
1211
+ fd, path = tempfile.mkstemp(suffix=suffix)
1212
+ os.close(fd)
1213
+ with open(path, "wb") as f:
1214
+ f.write(audio)
1215
+ return path
1216
+
1217
+
1218
+ def _build_gradio_ui(gr):
1219
+ ui_engines = available_engines()
1220
+ css = """
1221
+ .gradio-container {
1222
+ max-width: 1160px !important;
1223
+ margin: auto !important;
1224
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
1225
+ }
1226
+ .voicecraft-hero {
1227
+ border: 1px solid rgba(148, 163, 184, 0.24);
1228
+ border-radius: 18px;
1229
+ padding: 22px 24px;
1230
+ background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(17, 24, 39, 0.92));
1231
+ color: white;
1232
+ box-shadow: 0 22px 60px rgba(15, 23, 42, 0.18);
1233
+ }
1234
+ .voicecraft-hero h1 {
1235
+ margin: 0 0 8px;
1236
+ font-size: 30px;
1237
+ line-height: 1.1;
1238
+ letter-spacing: 0;
1239
+ }
1240
+ .voicecraft-hero p {
1241
+ margin: 0;
1242
+ color: rgba(226, 232, 240, 0.88);
1243
+ }
1244
+ """
1245
+
1246
+ async def generate_audio(engine, text, voice, rate, volume, pitch, speed, style, styledegree, reference_audio, token):
1247
+ if not API_SECRET or not secrets.compare_digest((token or "").strip(), API_SECRET):
1248
+ raise gr.Error("Invalid API token")
1249
+
1250
+ clean_text = (text or "").strip()
1251
+ if not clean_text:
1252
+ raise gr.Error("Text is empty")
1253
+ if len(clean_text) > 60000:
1254
+ raise gr.Error("Text is too long for one browser request. Use the desktop app for automatic long-script batching.")
1255
+
1256
+ engine = (engine or "edge").strip().lower()
1257
+ if engine in CLONE_ENGINES and not clone_engines_enabled():
1258
+ raise gr.Error("Clone engines are disabled on this Space")
1259
+ voice = (voice or GRADIO_DEFAULT_VOICES.get(engine) or GRADIO_DEFAULT_VOICES["edge"]).strip()
1260
+ style = (style or "").strip() or None
1261
+ styledegree_value = str(styledegree) if styledegree is not None else None
1262
+ reference_path = reference_audio if isinstance(reference_audio, str) and reference_audio else None
1263
+
1264
+ try:
1265
+ if engine == "edge":
1266
+ audio = await synthesize_edge(
1267
+ clean_text,
1268
+ voice,
1269
+ rate=rate,
1270
+ volume=volume,
1271
+ pitch=pitch,
1272
+ style=style,
1273
+ styledegree=styledegree_value,
1274
+ )
1275
+ return _write_audio_file(audio, ".mp3"), "Ready - Edge audio generated."
1276
+
1277
+ if engine == "piper":
1278
+ audio = await asyncio.to_thread(synthesize_piper, clean_text, voice, float(speed or 1.0))
1279
+ return _write_audio_file(audio, ".wav"), "Ready - Piper audio generated."
1280
+
1281
+ if engine == "silero":
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."
1292
+
1293
+ raise gr.Error(f"Unknown engine: {engine}")
1294
+
1295
+ except Exception as e:
1296
+ logging.error("Gradio synthesis failed: %s", e, exc_info=True)
1297
+ raise gr.Error(f"Synthesis failed: {str(e)[:220]}")
1298
+
1299
+ def default_voice_for_engine(engine):
1300
+ return GRADIO_DEFAULT_VOICES.get((engine or "edge").lower(), GRADIO_DEFAULT_VOICES["edge"])
1301
+
1302
+ with gr.Blocks(title="VoiceCraft TTS Server", css=css) as demo:
1303
+ gr.HTML(
1304
+ """
1305
+ <div class="voicecraft-hero">
1306
+ <h1>VoiceCraft TTS Server</h1>
1307
+ <p>FastAPI endpoints are live for the desktop app. This panel is only for quick browser testing.</p>
1308
+ </div>
1309
+ """
1310
+ )
1311
+ with gr.Row():
1312
+ with gr.Column(scale=3):
1313
+ text = gr.Textbox(
1314
+ label="Script",
1315
+ lines=10,
1316
+ placeholder="Paste text here...",
1317
+ value="Hello from VoiceCraft. Your deployment is ready for a quick audio test.",
1318
+ )
1319
+ with gr.Row():
1320
+ engine = gr.Dropdown(
1321
+ label="Engine",
1322
+ choices=ui_engines,
1323
+ value="edge",
1324
+ )
1325
+ voice = gr.Textbox(label="Voice code", value=GRADIO_DEFAULT_VOICES["edge"])
1326
+ reference_audio = gr.Audio(label="Reference audio for cloning", sources=["upload"], type="filepath")
1327
+ token = gr.Textbox(label="API token", type="password", placeholder="Required when API_SECRET is set")
1328
+ with gr.Column(scale=2):
1329
+ rate = gr.Textbox(label="Edge rate", value="+0%")
1330
+ volume = gr.Textbox(label="Edge volume", value="+0%")
1331
+ pitch = gr.Textbox(label="Edge pitch", value="+0Hz")
1332
+ style = gr.Textbox(label="Edge style", placeholder="cheerful, sad, whispering...")
1333
+ styledegree = gr.Slider(label="Style degree", minimum=0.0, maximum=2.0, value=1.0, step=0.1)
1334
+ speed = gr.Slider(label="Piper speed", minimum=0.65, maximum=1.35, value=1.0, step=0.05)
1335
+ run = gr.Button("Generate Audio", variant="primary")
1336
+ output_audio = gr.Audio(label="Output", type="filepath")
1337
+ status_box = gr.Markdown("Ready.")
1338
+ gr.Markdown("API paths: `/tts`, `/health`, `/status`, `/all_voices`.")
1339
+
1340
+ engine.change(default_voice_for_engine, inputs=engine, outputs=voice)
1341
+ run.click(
1342
+ generate_audio,
1343
+ inputs=[engine, text, voice, rate, volume, pitch, speed, style, styledegree, reference_audio, token],
1344
+ outputs=[output_audio, status_box],
1345
+ )
1346
+
1347
+ return demo
1348
+
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__":
1363
+ port = int(os.environ.get("PORT") or os.environ.get("GRADIO_SERVER_PORT") or 7860)
1364
+ app.launch(
1365
+ server_name="0.0.0.0",
1366
+ server_port=port,
1367
+ share=False,
1368
+ show_error=True,
1369
+ )
requirements-docker.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ gradio==6.20.0
4
+ edge-tts
5
+ python-multipart
6
+ requests
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.
requirements.txt CHANGED
@@ -1,8 +1,10 @@
1
  fastapi
2
- uvicorn
 
3
  edge-tts
4
  python-multipart
5
  requests
6
- numpy<2
7
- scipy
8
- torch==2.1.2
 
 
1
  fastapi
2
+ uvicorn[standard]
3
+ gradio==6.20.0
4
  edge-tts
5
  python-multipart
6
  requests
7
+ numpy>=2.0,<3
8
+ scipy>=1.15.3
9
+ soundfile>=0.12,<1
10
+ pocket-tts>=2.1.0