Files changed (1) hide show
  1. app.py +1315 -576
app.py CHANGED
@@ -1,160 +1,208 @@
1
- """
2
- RVC Voice Conversion – HuggingFace Space
3
- Simple, fast, GPU/CPU auto-detected.
4
- """
5
  from __future__ import annotations
6
 
7
  import logging
8
  import os
9
- import queue
10
- import shutil
11
  import sys
12
- import tempfile
13
- import threading
14
  import time
15
- import uuid
16
  import zipfile
17
- from concurrent.futures import ThreadPoolExecutor, as_completed
 
 
 
 
 
 
18
  from pathlib import Path
 
 
 
19
 
20
- import torch
21
-
22
- # ── Path bootstrap ────────────────────────────────────────────────────────────
23
- BASE_DIR = Path(__file__).parent
24
- sys.path.insert(0, str(BASE_DIR))
25
-
26
- MODELS_DIR = BASE_DIR / "rvc_models"
27
- OUTPUT_DIR = BASE_DIR / "outputs"
28
- MODELS_DIR.mkdir(exist_ok=True)
29
- OUTPUT_DIR.mkdir(exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- os.environ.setdefault("URVC_MODELS_DIR", str(MODELS_DIR / "urvc"))
 
 
 
 
 
 
 
 
 
 
32
 
 
 
 
33
  logging.basicConfig(
34
  level=logging.INFO,
35
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
36
  datefmt="%H:%M:%S",
37
  )
38
-
39
  for _noisy in ("httpx", "httpcore", "faiss", "faiss.loader", "transformers", "torch"):
40
  logging.getLogger(_noisy).setLevel(logging.WARNING)
41
  logger = logging.getLogger("rvc_space")
42
 
43
- # ── CPU threading ─────────────────────────────────────────────────────────────
44
- try:
45
- _NUM_CORES = len(os.sched_getaffinity(0))
46
- except AttributeError:
47
- _NUM_CORES = os.cpu_count() or 1
48
- torch.set_num_threads(_NUM_CORES)
49
- torch.set_num_interop_threads(_NUM_CORES)
50
- os.environ["OMP_NUM_THREADS"] = str(_NUM_CORES)
51
- os.environ["MKL_NUM_THREADS"] = str(_NUM_CORES)
52
- os.environ["NUMEXPR_NUM_THREADS"] = str(_NUM_CORES)
53
- os.environ["OPENBLAS_NUM_THREADS"] = str(_NUM_CORES)
54
- torch.set_float32_matmul_precision("high")
55
- torch.backends.mkldnn.enabled = True
56
- logger.info("CPU threads: %d | matmul: high | oneDNN: enabled", _NUM_CORES)
57
-
58
- # ── Device ────────────────────────────────────────────────────────────────────
59
- if torch.cuda.is_available():
60
- DEVICE = "cuda"
61
- DEVICE_LABEL = f"🟒 GPU · {torch.cuda.get_device_name(0)}"
62
- else:
63
- DEVICE = "cpu"
64
- DEVICE_LABEL = f"πŸ”΅ CPU Β· {_NUM_CORES} cores"
65
- logger.info("Device: %s", DEVICE_LABEL)
66
-
67
- # ── Built-in models ───────────────────────────────────────────────────────────
68
- BUILTIN_MODELS = [
69
- {
70
- "name": "Vestia Zeta v1",
71
- "url": "https://huggingface.co/megaaziib/my-rvc-models-collection/resolve/main/zeta.zip",
72
- },
73
- {
74
- "name": "Vestia Zeta v2",
75
- "url": "https://huggingface.co/megaaziib/my-rvc-models-collection/resolve/main/zetaTest.zip",
76
- },
77
- {
78
- "name": "Ayunda Risu",
79
- "url": "https://huggingface.co/megaaziib/my-rvc-models-collection/resolve/main/risu.zip",
80
- },
81
- {
82
- "name": "Gawr Gura",
83
- "url": "https://huggingface.co/Gigrig/GigrigRVC/resolve/41d46f087b9c7d70b93acf100f1cb9f7d25f3831/GawrGura_RVC_v2_Ov2Super_e275_s64075.zip",
84
- },
85
- ]
86
-
87
- # Max input duration in seconds (warn user beyond this)
88
- MAX_INPUT_DURATION = 300 # 5 minutes
89
 
90
- # Output file TTL β€” delete files older than this on each conversion
91
- OUTPUT_TTL_SECONDS = 21600 # 1 hour
 
 
92
 
93
- # Max jobs to keep in memory
94
- MAX_JOBS = 50
95
 
96
- # ── Lazy VoiceConverter ───────────────────────────────────────────────────────
97
- _vc_instance = None
 
 
 
 
98
 
 
 
 
 
 
 
 
99
 
100
- def _get_vc():
101
- global _vc_instance
102
- if _vc_instance is None:
103
- logger.info("Loading VoiceConverter…")
104
- from ultimate_rvc.rvc.infer.infer import VoiceConverter
105
- _vc_instance = VoiceConverter()
106
- logger.info("VoiceConverter ready.")
107
- return _vc_instance
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
 
110
- # ── Output file cleanup ───────────────────────────────────────────────────────
111
- def _cleanup_old_outputs() -> None:
112
- """Delete output files older than OUTPUT_TTL_SECONDS."""
113
- now = time.time()
114
- for f in OUTPUT_DIR.iterdir():
115
- if f.is_file() and (now - f.stat().st_mtime) > OUTPUT_TTL_SECONDS:
116
- try:
117
- f.unlink()
118
- logger.info("Cleaned up old output: %s", f.name)
119
- except Exception:
120
- pass
121
 
122
 
123
- # ── Model helpers ─────────────────────────────────────────────────────────────
124
- def list_models() -> list[str]:
125
- if not MODELS_DIR.exists():
126
- return []
127
- return sorted(p.name for p in MODELS_DIR.iterdir()
128
- if p.is_dir() and list(p.glob("*.pth")))
 
 
 
129
 
130
 
131
- def _pth_and_index(name: str) -> tuple[str, str]:
132
- d = MODELS_DIR / name
133
- pths = list(d.glob("*.pth"))
134
- idxs = list(d.glob("*.index"))
135
- if not pths:
136
- raise FileNotFoundError(f"No .pth file found in model '{name}'")
137
- return str(pths[0]), str(idxs[0]) if idxs else ""
 
 
 
 
 
 
 
 
 
 
 
138
 
139
 
140
- def _extract_zip(zip_path: str | Path, dest_name: str) -> None:
141
- dest = MODELS_DIR / dest_name
142
- dest.mkdir(exist_ok=True)
143
- with zipfile.ZipFile(zip_path, "r") as zf:
144
- zf.extractall(dest)
145
- for nested in list(dest.rglob("*.pth")) + list(dest.rglob("*.index")):
146
- target = dest / nested.name
147
- if nested != target:
148
- shutil.move(str(nested), str(target))
149
 
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  def _download_file(url: str, dest: Path) -> None:
152
- """Download a single file if not already present."""
153
  if dest.exists():
154
  return
155
  dest.parent.mkdir(parents=True, exist_ok=True)
156
- logger.info("Downloading %s …", dest.name)
157
- import requests
158
  r = requests.get(url, stream=True, timeout=300)
159
  r.raise_for_status()
160
  with tempfile.NamedTemporaryFile(delete=False, dir=dest.parent, suffix=".tmp") as tmp:
@@ -165,15 +213,25 @@ def _download_file(url: str, dest: Path) -> None:
165
  logger.info("%s ready.", dest.name)
166
 
167
 
 
 
 
 
 
 
 
 
 
 
 
168
  def _download_model_entry(model: dict) -> str:
169
- """Download a single built-in model zip. Returns model name."""
170
- import requests
171
  name = model["name"]
172
  dest = MODELS_DIR / name
173
  if dest.exists() and list(dest.glob("*.pth")):
174
  logger.info("Model already present: %s", name)
175
  return name
176
- logger.info("Downloading model: %s …", name)
177
  with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
178
  r = requests.get(model["url"], stream=True, timeout=300)
179
  r.raise_for_status()
@@ -187,33 +245,22 @@ def _download_model_entry(model: dict) -> str:
187
 
188
 
189
  def _startup_downloads() -> str:
190
- """
191
- Download all required assets in parallel at startup.
192
- Returns name of first built-in model as the default selection.
193
- """
194
- import requests # noqa: F401 β€” ensure available before threads
195
-
196
- # Build task list: predictors + embedders + models all in one pool
197
  predictor_base = "https://huggingface.co/JackismyShephard/ultimate-rvc/resolve/main/Resources/predictors"
198
- embedder_base = "https://huggingface.co/JackismyShephard/ultimate-rvc/resolve/main/Resources/embedders"
199
- predictors_dir = MODELS_DIR / "urvc" / "rvc" / "predictors"
200
- embedders_dir = MODELS_DIR / "urvc" / "rvc" / "embedders"
201
 
202
  file_tasks = [
203
- (f"{predictor_base}/rmvpe.pt", predictors_dir / "rmvpe.pt"),
204
- (f"{predictor_base}/fcpe.pt", predictors_dir / "fcpe.pt"),
205
  (f"{embedder_base}/contentvec/pytorch_model.bin", embedders_dir / "contentvec" / "pytorch_model.bin"),
206
- (f"{embedder_base}/contentvec/config.json", embedders_dir / "contentvec" / "config.json"),
207
  ]
208
 
209
  with ThreadPoolExecutor(max_workers=8) as pool:
210
- # Submit file downloads
211
- file_futures = {pool.submit(_download_file, url, dest): dest.name
212
- for url, dest in file_tasks}
213
- # Submit model downloads
214
- model_futures = {pool.submit(_download_model_entry, m): m["name"]
215
- for m in BUILTIN_MODELS}
216
-
217
  all_futures = {**file_futures, **model_futures}
218
  for future in as_completed(all_futures):
219
  try:
@@ -224,389 +271,988 @@ def _startup_downloads() -> str:
224
  return BUILTIN_MODELS[0]["name"]
225
 
226
 
227
- # ── Upload handler ────────────────────────────────────────────────────────────
228
- def upload_model(zip_file, model_name):
229
- import gradio as gr
230
- if not zip_file:
231
- return "⚠️ No file provided.", gr.update(), gr.update()
232
- name = (model_name or "").strip() or Path(zip_file).stem
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  try:
234
- _extract_zip(zip_file, name)
235
- models = list_models()
236
- return (
237
- f"βœ… Model **{name}** loaded successfully.",
238
- gr.update(choices=models, value=name),
239
- gr.update(value=[[m] for m in models]),
240
- )
241
- except Exception as exc:
242
- logger.exception("Model upload failed")
243
- return f"❌ Error: {exc}", gr.update(), gr.update()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
 
 
245
 
246
- # ── Refresh handler ───────────────────────────────────────────────────────────
247
- def refresh_models():
248
- import gradio as gr
249
- models = list_models()
250
- return gr.update(value=[[m] for m in models]), gr.update(choices=models)
251
 
252
 
253
- # ── Autotune visibility toggle ────────────────────────────────────────────────
254
- def toggle_autotune(enabled):
255
- import gradio as gr
256
- return gr.update(visible=enabled)
 
257
 
258
 
259
- # ── ffmpeg is pre-installed on HuggingFace Spaces ────────────────────────────
260
- def _ffmpeg_bin() -> str:
261
- return "ffmpeg"
 
 
 
 
 
 
 
262
 
263
 
264
- # ── Reverb effect via pedalboard ─────────────────────────────────────────────
265
- def _apply_reverb(audio_path: str, room_size: float, damping: float, wet_level: float) -> None:
266
- """Apply reverb in-place to a WAV file using pedalboard."""
267
- try:
268
- from pedalboard import Pedalboard, Reverb
269
- from pedalboard.io import AudioFile
270
- import tempfile, shutil
271
-
272
- tmp = audio_path + ".reverb.tmp.wav"
273
- board = Pedalboard([
274
- Reverb(
275
- room_size=room_size,
276
- damping=damping,
277
- wet_level=wet_level,
278
- dry_level=1.0 - wet_level,
279
- width=1.0,
280
- )
281
- ])
282
- with AudioFile(audio_path) as f:
283
- with AudioFile(tmp, "w", f.samplerate, f.num_channels) as out:
284
- while f.tell() < f.frames:
285
- chunk = f.read(f.samplerate)
286
- out.write(board(chunk, f.samplerate, reset=False))
287
- shutil.move(tmp, audio_path)
288
- logger.info("Reverb applied (room=%.2f, damp=%.2f, wet=%.2f)", room_size, damping, wet_level)
289
- except Exception as exc:
290
- logger.warning("Reverb failed: %s", exc)
291
-
292
-
293
- # ── Upload to temp.sh ────────────────────────────────────────────────────────
294
- def _upload_to_tempsh(file_path: str) -> str | None:
295
- """Upload a file to temp.sh and return the download URL, or None on failure."""
296
  try:
297
- import subprocess
298
- result = subprocess.run(
299
- ["curl", "-s", "-F", f"file=@{file_path}", "https://temp.sh/upload"],
300
- capture_output=True,
301
- text=True,
302
- timeout=120,
 
 
 
 
 
 
 
 
 
 
 
 
303
  )
304
- url = result.stdout.strip()
305
- if url.startswith("https://"):
306
- logger.info("Uploaded to temp.sh: %s", url)
307
- return url
308
- else:
309
- logger.warning("temp.sh upload failed: %s", result.stdout or result.stderr)
310
- return None
311
- except Exception as exc:
312
- logger.warning("temp.sh upload error: %s", exc)
313
- return None
314
 
315
 
316
- # ── Background job queue ─────────────────────────────────────────────────────
 
 
317
  _job_queue: queue.Queue = queue.Queue()
318
 
319
- # Job status store: job_id -> {"status": str, "url": str|None, "model": str}
320
- _jobs: dict[str, dict] = {}
321
- _jobs_lock = threading.Lock()
322
-
323
 
324
- def _worker() -> None:
325
- """Single background worker β€” processes one job at a time from the queue."""
326
- while True:
327
- job = _job_queue.get()
328
- job_id = job["id"]
329
- try:
330
- _start_time = time.time()
331
- with _jobs_lock:
332
- _jobs[job_id]["status"] = "⏳ Converting…"
333
-
334
- logger.info("[Job %s] Starting conversion (model: %s)", job_id, job["model_name"])
335
-
336
- model_path, index_path = _pth_and_index(job["model_name"])
337
- _cleanup_old_outputs()
338
-
339
- is_opus = job["output_format"].upper() == "OPUS"
340
- engine_format = "WAV" if is_opus else job["output_format"]
341
- ts = int(time.time())
342
- wav_path = OUTPUT_DIR / f"output-{ts}.wav"
343
- out_path = OUTPUT_DIR / (
344
- f"output-{ts}.opus" if is_opus
345
- else f"output-{ts}.{job['output_format'].lower()}"
346
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
- vc = _get_vc()
349
- vc.convert_audio(
350
- audio_input_path=job["audio_input"],
351
- audio_output_path=str(wav_path),
352
- model_path=model_path,
353
- index_path=index_path,
354
- pitch=job["pitch"],
355
- f0_method=job["f0_method"],
356
- index_rate=job["index_rate"],
357
- volume_envelope=job["volume_envelope"],
358
- protect=job["protect"],
359
- split_audio=job["split_audio"],
360
- f0_autotune=job["autotune"],
361
- f0_autotune_strength=job["autotune_strength"],
362
- clean_audio=job["clean_audio"],
363
- clean_strength=job["clean_strength"],
364
- export_format=engine_format,
365
- filter_radius=job["filter_radius"],
366
  )
367
 
368
- if is_opus:
369
- import subprocess
370
- subprocess.run(
371
- [
372
- _ffmpeg_bin(), "-y",
373
- "-i", str(wav_path),
374
- "-c:a", "libopus",
375
- "-b:a", "64000",
376
- "-vbr", "off",
377
- "-ar", "48000",
378
- str(out_path),
379
- ],
380
- check=True, capture_output=True,
381
- )
382
- wav_path.unlink(missing_ok=True)
383
-
384
- # Apply reverb if enabled (operates on the final output file)
385
- if job.get("reverb"):
386
- _apply_reverb(
387
- str(out_path),
388
- room_size=job.get("reverb_room_size", 0.15),
389
- damping=job.get("reverb_damping", 0.7),
390
- wet_level=job.get("reverb_wet_level", 0.15),
391
- )
392
-
393
- # Upload to temp.sh
394
- temp_url = _upload_to_tempsh(str(out_path))
395
-
396
- _elapsed = time.time() - _start_time
397
- _elapsed_str = f"{_elapsed:.0f}s" if _elapsed < 60 else f"{_elapsed/60:.1f}m"
398
- with _jobs_lock:
399
- _jobs[job_id]["elapsed"] = _elapsed_str
400
- if temp_url:
401
- _jobs[job_id]["status"] = "βœ… Done"
402
- _jobs[job_id]["url"] = temp_url
403
- _jobs[job_id]["file"] = str(out_path)
404
- logger.info("[Job %s] Complete in %s β†’ %s", job_id, _elapsed_str, temp_url)
405
- else:
406
- _jobs[job_id]["status"] = "βœ… Done"
407
- _jobs[job_id]["file"] = str(out_path)
408
- logger.info("[Job %s] Complete in %s (no temp.sh URL)", job_id, _elapsed_str)
409
-
410
- except Exception as exc:
411
- _elapsed = time.time() - _start_time if "_start_time" in dir() else 0
412
- _elapsed_str = f"{_elapsed:.0f}s" if _elapsed < 60 else f"{_elapsed/60:.1f}m"
413
- logger.exception("[Job %s] Failed after %s: %s", job_id, _elapsed_str, exc)
414
- with _jobs_lock:
415
- _jobs[job_id]["status"] = f"❌ Failed"
416
- _jobs[job_id]["elapsed"] = _elapsed_str
417
- _jobs[job_id]["file"] = None
 
 
 
 
 
 
418
  finally:
419
- _job_queue.task_done()
 
 
 
 
420
 
421
 
422
- # Start the single background worker thread
423
- _worker_thread = threading.Thread(target=_worker, daemon=True)
424
  _worker_thread.start()
425
  logger.info("Background worker started.")
426
 
427
 
428
- # ── Conversion ────────────────────────────────────────────────────────────────
429
- def convert(
430
- audio_mic, audio_file, model_name,
431
- pitch, f0_method,
432
- index_rate, protect, volume_envelope,
433
- clean_audio, clean_strength,
434
- split_audio, autotune, autotune_strength,
435
- filter_radius,
436
- output_format,
437
- reverb=False,
438
- reverb_room_size=0.15,
439
- reverb_damping=0.7,
440
- reverb_wet_level=0.15,
441
- ):
442
- """Submit a job to the background worker and return immediately."""
443
- audio_input = audio_mic or audio_file
444
- if audio_input is None:
445
- return "⚠️ Please record or upload audio first.", None
446
- if not model_name:
447
- return "⚠️ No model selected.", None
448
-
449
- # Check input duration upfront before queuing
450
  try:
451
- import soundfile as sf
452
- info = sf.info(audio_input)
453
- duration = info.duration
454
- if duration > MAX_INPUT_DURATION:
455
- return (
456
- f"⚠️ Audio is {duration:.0f}s β€” max is {MAX_INPUT_DURATION//60} min. "
457
- f"Please trim your audio.", None
458
- )
459
- logger.info("Input duration: %.1fs", duration)
460
- except Exception:
461
- pass
462
-
463
- # Validate model exists before queuing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  try:
465
- _pth_and_index(model_name)
466
- except FileNotFoundError as exc:
467
- return f"❌ {exc}", None
468
-
469
- job_id = uuid.uuid4().hex[:8]
470
- job = {
471
- "id": job_id,
472
- "audio_input": audio_input,
473
- "model_name": model_name,
474
- "pitch": pitch,
475
- "f0_method": f0_method,
476
- "index_rate": index_rate,
477
- "volume_envelope": volume_envelope,
478
- "protect": protect,
479
- "split_audio": split_audio,
480
- "autotune": autotune,
481
- "autotune_strength": autotune_strength,
482
- "clean_audio": clean_audio,
483
- "clean_strength": clean_strength,
484
- "filter_radius": filter_radius,
485
- "output_format": output_format,
486
- "reverb": reverb,
487
- "reverb_room_size": reverb_room_size,
488
- "reverb_damping": reverb_damping,
489
- "reverb_wet_level": reverb_wet_level,
490
- }
491
 
492
- with _jobs_lock:
493
- if len(_jobs) >= MAX_JOBS:
494
- oldest = next(iter(_jobs))
495
- del _jobs[oldest]
496
- logger.info("Removed oldest job %s (limit: %d)", oldest, MAX_JOBS)
497
- _jobs[job_id] = {"status": "πŸ• Queued…", "url": None, "file": None, "model": model_name}
498
 
499
- _job_queue.put(job)
500
- queue_size = _job_queue.qsize()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
 
502
- logger.info("[Job %s] Queued (model: %s, queue depth: %d)", job_id, model_name, queue_size)
503
 
504
- msg = (
505
- "πŸ• Job **" + job_id + "** queued β€” you can close this tab.\n\n"
506
- "Check the **πŸ“‹ Jobs** tab for your download link when done.\n\n"
507
- "_(Queue position: " + str(queue_size) + ")_"
508
- )
509
- return msg, None
510
 
511
 
512
- def poll_job(job_id: str) -> tuple[str, str | None]:
513
- """Check status of a submitted job. Returns (status_msg, file_path_or_None)."""
514
- with _jobs_lock:
515
- job = _jobs.get(job_id)
516
- if not job:
517
- return f"❌ Job {job_id} not found.", None
518
- status = job["status"]
519
- url = job.get("url")
520
- file = job.get("file")
521
- if url:
522
- return f"{status} Β· πŸ”— [Download link]({url}) Β· _(expires in 3 days)_", file
523
- return status, file
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
 
526
- # ── Startup ───────────────────────────────────────────────────────────────────
527
- _startup_status = ""
528
- _default_model = ""
529
- try:
530
- _default_model = _startup_downloads()
531
- _startup_status = f"βœ… Ready &nbsp;Β·&nbsp; {DEVICE_LABEL}"
532
- except Exception as _e:
533
- _startup_status = f"⚠️ Some assets unavailable: {_e} &nbsp;·&nbsp; {DEVICE_LABEL}"
534
- logger.warning("Startup download issue: %s", _e)
535
-
536
- _initial_models = list_models()
537
- _initial_value = _default_model if _default_model in _initial_models else (
538
- _initial_models[0] if _initial_models else None
539
- )
540
 
 
 
541
 
542
- # ── Log helpers ───────────────────────────────────────────────────────────────
 
 
543
 
 
544
 
545
- def get_jobs_table() -> list[list]:
546
- """Return job list as rows: [ID, Model, Status, Time, Download Link]."""
547
- with _jobs_lock:
548
- jobs = list(_jobs.items())
549
- if not jobs:
550
- return [["β€”", "β€”", "No jobs yet", "β€”", "β€”"]]
551
- rows = []
552
- for job_id, info in reversed(jobs):
553
- url = info.get("url")
554
- link = f"[⬇️]({url})" if url else "β€”"
555
- rows.append([
556
- job_id,
557
- info.get("model", ""),
558
- info.get("status", ""),
559
- info.get("elapsed", "β€”"),
560
- link,
561
- ])
562
- return rows
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
 
 
 
564
 
565
- def get_queue_info() -> str:
566
- """Return a short queue status string."""
567
- qs = _job_queue.qsize()
568
- total = len(_jobs)
569
- running = sum(1 for j in _jobs.values() if j.get("status", "").startswith("⏳"))
570
- done = sum(1 for j in _jobs.values() if j.get("status", "").startswith("βœ…"))
571
- failed = sum(1 for j in _jobs.values() if j.get("status", "").startswith("❌"))
572
- return (
573
- f"**Queue:** {qs} waiting Β· "
574
- f"**Running:** {running} Β· "
575
- f"**Done:** {done} Β· "
576
- f"**Failed:** {failed} Β· "
577
- f"**Total:** {total}"
578
- )
579
 
580
 
581
- # ── Gradio UI ─────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  import gradio as gr
583
 
584
- _CSS = """
585
- #header { text-align: center; padding: 20px 0 8px; }
586
- #header h1 { font-size: 2rem; margin: 0; }
587
- #header p { opacity: .65; margin: 4px 0 0; }
588
- #status { text-align: center; font-size: .82rem; opacity: .7; margin-bottom: 8px; }
589
- footer { display: none !important; }
590
- """
591
 
592
- with gr.Blocks(title="RVC Voice Conversion", delete_cache=(3600, 3600)) as demo:
593
 
594
  gr.HTML(f"""
595
- <div id="header">
596
- <h1>πŸŽ™οΈ RVC Voice Conversion</h1>
597
- <p>Retrieval-Based Voice Conversion Β· record or upload Β· custom models Β· GPU/CPU auto</p>
598
- </div>
599
- <p id="status">{_startup_status}</p>
600
  """)
601
 
602
  with gr.Tabs():
603
 
604
  # ── TAB 1: Convert ────────────────────────────────────────────────────
605
- with gr.Tab("🎀 Convert"):
606
  with gr.Row():
607
-
608
  with gr.Column(scale=1):
609
- gr.Markdown("### πŸ”Š Input Audio")
610
  with gr.Tabs():
611
  with gr.Tab("πŸŽ™οΈ Microphone"):
612
  inp_mic = gr.Audio(
@@ -620,11 +1266,15 @@ with gr.Blocks(title="RVC Voice Conversion", delete_cache=(3600, 3600)) as demo:
620
  type="filepath",
621
  label="Upload audio (wav / mp3 / flac / ogg …)",
622
  )
 
 
 
 
623
 
624
  gr.Markdown("### πŸ€– Model")
625
  model_dd = gr.Dropdown(
626
- choices=_initial_models,
627
- value=_initial_value,
628
  label="Active Voice Model",
629
  interactive=True,
630
  )
@@ -672,71 +1322,71 @@ with gr.Blocks(title="RVC Voice Conversion", delete_cache=(3600, 3600)) as demo:
672
  label="Reduction Strength",
673
  )
674
  with gr.Row():
675
- split_cb = gr.Checkbox(value=False, label="Split Long Audio")
676
  autotune_cb = gr.Checkbox(value=False, label="Autotune")
677
- autotune_sl = gr.Slider(
678
- 0.0, 1.0, value=1.0, step=0.05,
679
- label="Autotune Strength",
680
- visible=False,
681
- )
682
- autotune_cb.change(
683
- fn=toggle_autotune,
684
- inputs=autotune_cb,
685
- outputs=autotune_sl,
686
- )
687
-
688
- gr.Markdown("**πŸŽ›οΈ Reverb**")
689
- reverb_cb = gr.Checkbox(value=False, label="Enable Reverb")
690
- with gr.Group(visible=False) as reverb_group:
691
- reverb_room_sl = gr.Slider(
692
- 0.0, 1.0, value=0.15, step=0.05,
693
- label="Room Size",
694
- info="Larger = bigger sounding space",
695
  )
696
- reverb_damp_sl = gr.Slider(
697
- 0.0, 1.0, value=0.7, step=0.05,
698
- label="Damping",
699
- info="Higher = more absorption, less echo tail",
700
  )
701
- reverb_wet_sl = gr.Slider(
702
- 0.0, 1.0, value=0.15, step=0.05,
703
- label="Wet Level",
704
- info="How much reverb is mixed in (0.15 = subtle)",
705
- )
706
- reverb_cb.change(
707
- fn=lambda v: gr.update(visible=v),
708
- inputs=reverb_cb,
709
- outputs=reverb_group,
 
 
 
 
710
  )
 
 
 
 
 
 
 
 
 
 
711
 
712
  fmt_radio = gr.Radio(
713
- choices=["WAV", "MP3", "FLAC", "OPUS"],
714
  value="WAV",
715
  label="Output Format",
716
  info="OPUS = small file (~64 kbps, Telegram/Discord quality)",
717
  )
718
  convert_btn = gr.Button(
719
- "πŸš€ Convert Voice",
720
  variant="primary",
721
  )
722
 
723
  gr.Markdown("### 🎧 Output")
724
  out_status = gr.Markdown(value="")
725
- out_audio = gr.Audio(label="Result (if still on page)", type="filepath", interactive=False)
726
 
727
  gr.Markdown("#### πŸ” Check Job Status")
728
  with gr.Row():
729
- job_id_box = gr.Textbox(
730
  label="Job ID",
731
- placeholder="e.g. a3f2b1c9",
732
  scale=3,
733
  )
734
  poll_btn = gr.Button("πŸ”„ Check", scale=1)
735
  poll_status = gr.Markdown(value="")
736
- poll_audio = gr.Audio(label="Result", type="filepath", interactive=False)
737
 
738
  # ── TAB 2: Models ─────────────────────────────────────────────────────
739
- with gr.Tab("πŸ“¦ Models"):
740
  gr.Markdown("""
741
  ### Upload a Custom RVC Model
742
  Provide a **`.zip`** containing:
@@ -748,22 +1398,23 @@ with gr.Blocks(title="RVC Voice Conversion", delete_cache=(3600, 3600)) as demo:
748
  """)
749
  with gr.Row():
750
  with gr.Column(scale=1):
751
- up_zip = gr.File(label="Model ZIP", file_types=[".zip"])
752
- up_name = gr.Textbox(
753
  label="Model Name",
754
  placeholder="Leave blank to use zip filename",
755
  )
756
- up_btn = gr.Button("πŸ“€ Load Model", variant="primary")
757
  up_status = gr.Textbox(label="Status", interactive=False, lines=2)
758
  with gr.Column(scale=1):
759
  gr.Markdown("### Loaded Models")
760
  models_table = gr.Dataframe(
761
- col_count=(1, "fixed"),
762
- value=[[m] for m in _initial_models],
 
763
  interactive=False,
764
  label="",
765
  )
766
- refresh_btn = gr.Button("πŸ”„ Refresh")
767
 
768
  up_btn.click(
769
  fn=upload_model,
@@ -771,88 +1422,170 @@ with gr.Blocks(title="RVC Voice Conversion", delete_cache=(3600, 3600)) as demo:
771
  outputs=[up_status, model_dd, models_table],
772
  )
773
  refresh_btn.click(
774
- fn=refresh_models,
775
  outputs=[models_table, model_dd],
776
  )
777
 
 
 
 
 
 
 
 
 
 
 
 
778
  # ── TAB 3: Jobs ───────────────────────────────────────────────────────
779
  with gr.Tab("πŸ“‹ Jobs"):
780
  gr.Markdown("All submitted jobs, newest first. Click **Refresh** to update.")
781
- queue_status = gr.Markdown(value=get_queue_info, every=10)
782
  jobs_table = gr.Dataframe(
783
- headers=["Job ID", "Model", "Status", "Time", "Download"],
784
- col_count=(5, "fixed"),
785
- value=get_jobs_table,
786
- interactive=False,
787
- wrap=True,
788
- datatype=["str", "str", "str", "str", "markdown"],
789
- every=10,
790
- )
791
- refresh_jobs_btn = gr.Button("πŸ”„ Refresh")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
792
 
793
- def _refresh_jobs():
794
- return get_queue_info(), get_jobs_table()
 
 
 
 
 
 
 
 
 
 
 
 
 
795
 
796
- refresh_jobs_btn.click(fn=_refresh_jobs, outputs=[queue_status, jobs_table])
 
797
 
798
- # ── TAB 4: Help ───────────────────────────────────────────────────────
799
- with gr.Tab("ℹ️ Help"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  gr.Markdown(f"""
801
- ## How it works
802
- RVC (Retrieval-Based Voice Conversion) transforms a voice recording to sound
803
- like a target speaker using only that speaker's model file.
804
 
805
  ---
806
 
807
- ## Quick Guide
808
- 1. Open the **Convert** tab
809
- 2. **Record** via microphone or **upload** an audio file (wav, mp3, flac, ogg …)
810
- 3. Choose a **model** from the dropdown β€” 4 models are pre-loaded on startup
811
- 4. Set **Pitch Shift** if needed (e.g. male β†’ female: try +12 semitones)
812
- 5. Click **πŸš€ Convert Voice** and wait for the result
 
 
813
 
814
  ---
815
 
816
- ## Built-in Models
817
- | Model | Description |
818
  |---|---|
819
- | **Vestia Zeta v1** | Hololive ID VTuber, v1 model |
820
- | **Vestia Zeta v2** | Hololive ID VTuber, v2 model (recommended) |
821
  | **Ayunda Risu** | Hololive ID VTuber |
822
  | **Gawr Gura** | Hololive EN VTuber |
823
 
824
  ---
825
 
826
- ## Pitch Extraction Methods
827
- | Method | Speed | Quality | Best for |
828
  |---|---|---|---|
829
- | **rmvpe** | ⚑⚑⚑ | β˜…β˜…β˜…β˜… | General use (default) |
830
- | **fcpe** | ⚑⚑ | β˜…β˜…β˜…β˜… | Singing |
831
- | **crepe** | ⚑ | β˜…β˜…β˜…β˜…β˜… | Highest quality, slow |
832
- | **crepe-tiny** | ⚑⚑ | β˜…β˜…β˜… | Low resource |
833
 
834
  ---
835
 
836
- ## Advanced Settings
837
- | Setting | Description |
838
  |---|---|
839
- | **Index Rate** | Influence of FAISS index on output timbre (0.75 recommended) |
840
- | **Protect Consonants** | Prevents artefacts on consonants (0.5 = max) |
841
- | **Respiration Filter Radius** | Smooths pitch curve β€” higher reduces breath noise (0–7, default 3) |
842
- | **Volume Envelope Mix** | 0.25 = natural blend Β· 1 = preserve input loudness |
843
- | **Noise Reduction** | Removes background noise before conversion |
844
- | **Split Long Audio** | Chunks audio for recordings > 60 s |
845
- | **Autotune** | Snaps pitch to nearest musical note |
 
846
 
847
  ---
848
 
849
- ## Output Formats
850
- | Format | Size | Quality |
851
- |---|---|---|
852
- | **WAV** | Large | Lossless |
853
- | **FLAC** | Medium | Lossless compressed |
854
- | **MP3** | Small | Lossy |
855
- | **OPUS** | Tiny (~64 kbps) | Telegram/Discord quality |
856
 
857
  ---
858
 
@@ -861,22 +1594,29 @@ with gr.Blocks(title="RVC Voice Conversion", delete_cache=(3600, 3600)) as demo:
861
 
862
  ---
863
 
864
- ## Credits
865
  Engine: [Ultimate RVC](https://github.com/JackismyShephard/ultimate-rvc)
866
  """)
867
 
868
- # Wire convert button after all tabs so jobs_table is defined
869
- def _submit_and_extract_id(*args):
870
- status, audio = convert(*args)
871
- import re
872
- match = re.search(r"[a-f0-9]{8}", status or "")
 
 
 
 
 
 
 
873
  job_id = match.group(0) if match else ""
874
  return status, audio, job_id, get_queue_info(), get_jobs_table()
875
 
876
  convert_btn.click(
877
  fn=_submit_and_extract_id,
878
  inputs=[
879
- inp_mic, inp_file, model_dd,
880
  pitch_sl, f0_radio,
881
  index_rate_sl, protect_sl, vol_env_sl,
882
  clean_cb, clean_sl,
@@ -905,7 +1645,6 @@ if __name__ == "__main__":
905
  demo.launch(
906
  server_name="0.0.0.0",
907
  server_port=int(os.getenv("PORT", 7860)),
908
- max_threads=10,
909
  ssr_mode=False,
910
- css=_CSS,
911
- )
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import logging
4
  import os
 
 
5
  import sys
6
+ import json
 
7
  import time
8
+ import shutil
9
  import zipfile
10
+ import threading
11
+ import traceback
12
+ import subprocess
13
+ import math
14
+ import re
15
+ import queue
16
+ import tempfile
17
  from pathlib import Path
18
+ from datetime import datetime
19
+ from typing import Optional, Dict, List, Tuple
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
 
22
+ import warnings
23
+ warnings.filterwarnings("ignore")
24
+
25
+ # =============================================================================
26
+ # CONFIGURACAO CRITICA: URVC_MODELS_DIR antes de qualquer import do ultimate_rvc
27
+ # =============================================================================
28
+ BASE_DIR = Path("/mnt/agents/output")
29
+ MODELS_DIR = BASE_DIR / "models"
30
+ OUTPUTS_DIR = BASE_DIR / "outputs"
31
+ JOBS_DIR = BASE_DIR / "jobs"
32
+ UPLOAD_TEMP = BASE_DIR / "upload_temp"
33
+
34
+ for d in [MODELS_DIR, OUTPUTS_DIR, JOBS_DIR, UPLOAD_TEMP]:
35
+ d.mkdir(parents=True, exist_ok=True)
36
+
37
+ URVC_DIR = MODELS_DIR / "urvc"
38
+ os.environ.setdefault("URVC_MODELS_DIR", str(URVC_DIR))
39
+
40
+ HF_CACHE_DIR = BASE_DIR / "hf_cache"
41
+ HF_CACHE_DIR.mkdir(parents=True, exist_ok=True)
42
+ os.environ["HF_HOME"] = str(HF_CACHE_DIR)
43
+ os.environ["TRANSFORMERS_CACHE"] = str(HF_CACHE_DIR)
44
+ os.environ["HF_HUB_CACHE"] = str(HF_CACHE_DIR)
45
+
46
+ # =============================================================================
47
+ # CPU threading otimizado
48
+ # =============================================================================
49
+ try:
50
+ _NUM_CORES = len(os.sched_getaffinity(0))
51
+ except AttributeError:
52
+ _NUM_CORES = os.cpu_count() or 1
53
 
54
+ import torch
55
+ torch.set_num_threads(_NUM_CORES)
56
+ try:
57
+ torch.set_num_interop_threads(_NUM_CORES)
58
+ except RuntimeError:
59
+ pass # ja foi configurado em outro ponto do processo
60
+ os.environ["OMP_NUM_THREADS"] = str(_NUM_CORES)
61
+ os.environ["MKL_NUM_THREADS"] = str(_NUM_CORES)
62
+ os.environ["NUMEXPR_NUM_THREADS"] = str(_NUM_CORES)
63
+ os.environ["OPENBLAS_NUM_THREADS"] = str(_NUM_CORES)
64
+ torch.set_float32_matmul_precision("high")
65
 
66
+ # =============================================================================
67
+ # Logging
68
+ # =============================================================================
69
  logging.basicConfig(
70
  level=logging.INFO,
71
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
72
  datefmt="%H:%M:%S",
73
  )
 
74
  for _noisy in ("httpx", "httpcore", "faiss", "faiss.loader", "transformers", "torch"):
75
  logging.getLogger(_noisy).setLevel(logging.WARNING)
76
  logger = logging.getLogger("rvc_space")
77
 
78
+ # =============================================================================
79
+ # CONFIG
80
+ # =============================================================================
81
+ JOBS_FILE = JOBS_DIR / "jobs.json"
82
+ JOBS_LOCK = threading.Lock()
83
+ SR_TARGET = 48000
84
+ MAX_INPUT_DURATION = 600
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
+ STATUS_WAITING = "⏱️ Esperando"
87
+ STATUS_CONVERTING = "⏳ Convertendo"
88
+ STATUS_DONE = "βœ… Done"
89
+ STATUS_FAILED = "❌ Falha"
90
 
91
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
92
+ DEVICE_LABEL = f"{'GPU' if DEVICE == 'cuda' else 'CPU'} ({torch.cuda.get_device_name(0) if DEVICE == 'cuda' else 'CPU'})"
93
 
94
+ BUILTIN_MODELS = [
95
+ {"name": "Vestia Zeta v1", "url": "https://huggingface.co/megaaziib/my-rvc-models-collection/resolve/main/zeta.zip"},
96
+ {"name": "Vestia Zeta v2", "url": "https://huggingface.co/megaaziib/my-rvc-models-collection/resolve/main/zetaTest.zip"},
97
+ {"name": "Ayunda Risu", "url": "https://huggingface.co/megaaziib/my-rvc-models-collection/resolve/main/risu.zip"},
98
+ {"name": "Gawr Gura", "url": "https://huggingface.co/Gigrig/GigrigRVC/resolve/41d46f087b9c7d70b93acf100f1cb9f7d25f3831/GawrGura_RVC_v2_Ov2Super_e275_s64075.zip"},
99
+ ]
100
 
101
+ CSS = """
102
+ #header { text-align: center; margin-bottom: 1rem; }
103
+ #header h1 { margin-bottom: 0.2rem; }
104
+ #status { text-align: center; font-size: 0.9rem; color: #666; }
105
+ .gr-box { border-radius: 8px; }
106
+ footer { display: none !important; }
107
+ """
108
 
109
+ # =============================================================================
110
+ # DEPENDENCIES
111
+ # =============================================================================
112
+ def _install_package(package_name: str) -> bool:
113
+ try:
114
+ print(f"[INSTALL] Tentando instalar {package_name}...")
115
+ result = subprocess.run(
116
+ [sys.executable, "-m", "pip", "install", package_name, "-q"],
117
+ capture_output=True, text=True, timeout=180
118
+ )
119
+ if result.returncode == 0:
120
+ print(f"[INSTALL] {package_name} instalado com sucesso")
121
+ import importlib
122
+ importlib.invalidate_caches()
123
+ return True
124
+ else:
125
+ print(f"[INSTALL] {package_name} falhou: {result.stderr[-300:]}")
126
+ return False
127
+ except Exception as e:
128
+ print(f"[INSTALL] {package_name} erro: {e}")
129
+ return False
130
 
131
 
132
+ def _ensure_librosa():
133
+ try:
134
+ import librosa
135
+ return librosa
136
+ except ImportError:
137
+ _install_package("scipy")
138
+ if _install_package("librosa"):
139
+ import librosa
140
+ return librosa
141
+ raise RuntimeError("Nao foi possivel instalar librosa")
 
142
 
143
 
144
+ def _ensure_soundfile():
145
+ try:
146
+ import soundfile as sf
147
+ return sf
148
+ except ImportError:
149
+ if _install_package("soundfile"):
150
+ import soundfile as sf
151
+ return sf
152
+ raise RuntimeError("Nao foi possivel instalar soundfile")
153
 
154
 
155
+ def _ensure_ultimate_rvc():
156
+ try:
157
+ from ultimate_rvc.rvc.infer.infer import VoiceConverter
158
+ return VoiceConverter
159
+ except ImportError:
160
+ try:
161
+ result = subprocess.run(
162
+ [sys.executable, "-m", "pip", "install", "ultimate-rvc", "-q"],
163
+ capture_output=True, text=True, timeout=300
164
+ )
165
+ if result.returncode == 0:
166
+ import importlib
167
+ importlib.invalidate_caches()
168
+ from ultimate_rvc.rvc.infer.infer import VoiceConverter
169
+ return VoiceConverter
170
+ except Exception:
171
+ pass
172
+ raise RuntimeError("Nao foi possivel instalar ultimate-rvc")
173
 
174
 
175
+ def _ensure_demucs():
176
+ try:
177
+ import demucs
178
+ return demucs
179
+ except ImportError:
180
+ if _install_package("demucs"):
181
+ import demucs
182
+ return demucs
183
+ raise RuntimeError("Nao foi possivel instalar demucs")
184
 
185
 
186
+ def _ensure_requests():
187
+ try:
188
+ import requests
189
+ return requests
190
+ except ImportError:
191
+ if _install_package("requests"):
192
+ import requests
193
+ return requests
194
+ raise RuntimeError("Nao foi possivel instalar requests")
195
+
196
+
197
+ # =============================================================================
198
+ # DOWNLOAD HELPERS
199
+ # =============================================================================
200
  def _download_file(url: str, dest: Path) -> None:
201
+ requests = _ensure_requests()
202
  if dest.exists():
203
  return
204
  dest.parent.mkdir(parents=True, exist_ok=True)
205
+ logger.info("Downloading %s ...", dest.name)
 
206
  r = requests.get(url, stream=True, timeout=300)
207
  r.raise_for_status()
208
  with tempfile.NamedTemporaryFile(delete=False, dir=dest.parent, suffix=".tmp") as tmp:
 
213
  logger.info("%s ready.", dest.name)
214
 
215
 
216
+ def _extract_zip(zip_path: str | Path, dest_name: str) -> None:
217
+ dest = MODELS_DIR / dest_name
218
+ dest.mkdir(exist_ok=True)
219
+ with zipfile.ZipFile(zip_path, "r") as zf:
220
+ zf.extractall(dest)
221
+ for nested in list(dest.rglob("*.pth")) + list(dest.rglob("*.index")):
222
+ target = dest / nested.name
223
+ if nested != target:
224
+ shutil.move(str(nested), str(target))
225
+
226
+
227
  def _download_model_entry(model: dict) -> str:
228
+ requests = _ensure_requests()
 
229
  name = model["name"]
230
  dest = MODELS_DIR / name
231
  if dest.exists() and list(dest.glob("*.pth")):
232
  logger.info("Model already present: %s", name)
233
  return name
234
+ logger.info("Downloading model: %s ...", name)
235
  with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
236
  r = requests.get(model["url"], stream=True, timeout=300)
237
  r.raise_for_status()
 
245
 
246
 
247
  def _startup_downloads() -> str:
248
+ _ensure_requests()
 
 
 
 
 
 
249
  predictor_base = "https://huggingface.co/JackismyShephard/ultimate-rvc/resolve/main/Resources/predictors"
250
+ embedder_base = "https://huggingface.co/JackismyShephard/ultimate-rvc/resolve/main/Resources/embedders"
251
+ predictors_dir = URVC_DIR / "rvc" / "predictors"
252
+ embedders_dir = URVC_DIR / "rvc" / "embedders"
253
 
254
  file_tasks = [
255
+ (f"{predictor_base}/rmvpe.pt", predictors_dir / "rmvpe.pt"),
256
+ (f"{predictor_base}/fcpe.pt", predictors_dir / "fcpe.pt"),
257
  (f"{embedder_base}/contentvec/pytorch_model.bin", embedders_dir / "contentvec" / "pytorch_model.bin"),
258
+ (f"{embedder_base}/contentvec/config.json", embedders_dir / "contentvec" / "config.json"),
259
  ]
260
 
261
  with ThreadPoolExecutor(max_workers=8) as pool:
262
+ file_futures = {pool.submit(_download_file, url, dest): dest.name for url, dest in file_tasks}
263
+ model_futures = {pool.submit(_download_model_entry, m): m["name"] for m in BUILTIN_MODELS}
 
 
 
 
 
264
  all_futures = {**file_futures, **model_futures}
265
  for future in as_completed(all_futures):
266
  try:
 
271
  return BUILTIN_MODELS[0]["name"]
272
 
273
 
274
+ # =============================================================================
275
+ # JOBS SYSTEM
276
+ # =============================================================================
277
+ def load_jobs() -> dict:
278
+ with JOBS_LOCK:
279
+ if JOBS_FILE.exists():
280
+ try:
281
+ with open(JOBS_FILE, "r", encoding="utf-8") as f:
282
+ return json.load(f)
283
+ except Exception:
284
+ return {}
285
+ return {}
286
+
287
+
288
+ def save_jobs(jobs: dict):
289
+ with JOBS_LOCK:
290
+ JOBS_FILE.parent.mkdir(parents=True, exist_ok=True)
291
+ tmp = JOBS_FILE.with_suffix(".tmp")
292
+ with open(tmp, "w", encoding="utf-8") as f:
293
+ json.dump(jobs, f, ensure_ascii=False, indent=2)
294
+ os.replace(tmp, JOBS_FILE)
295
+
296
+
297
+ def create_job(job_id: str, model_name: str, pitch: int, f0_method: str,
298
+ input_file_path: str, settings: dict) -> dict:
299
+ jobs = load_jobs()
300
+ jobs[job_id] = {
301
+ "id": job_id,
302
+ "model": model_name,
303
+ "pitch": pitch,
304
+ "f0_method": f0_method,
305
+ "input_file": input_file_path,
306
+ "settings": settings,
307
+ "status": STATUS_WAITING,
308
+ "created_at": datetime.now().isoformat(),
309
+ "started_at": None,
310
+ "finished_at": None,
311
+ "error": None,
312
+ "outputs": {},
313
+ "log_file": str(JOBS_DIR / f"{job_id}.log")
314
+ }
315
+ save_jobs(jobs)
316
+ return jobs[job_id]
317
+
318
+
319
+ def update_job_status(job_id: str, status: str, error: str = None, outputs: dict = None):
320
+ jobs = load_jobs()
321
+ if job_id in jobs:
322
+ jobs[job_id]["status"] = status
323
+ if status == STATUS_CONVERTING and jobs[job_id]["started_at"] is None:
324
+ jobs[job_id]["started_at"] = datetime.now().isoformat()
325
+ if status in [STATUS_DONE, STATUS_FAILED]:
326
+ jobs[job_id]["finished_at"] = datetime.now().isoformat()
327
+ if error:
328
+ jobs[job_id]["error"] = error
329
+ if outputs:
330
+ jobs[job_id]["outputs"] = outputs
331
+ save_jobs(jobs)
332
+
333
+
334
+ def append_log(job_id: str, message: str):
335
+ if job_id == "_global":
336
+ return
337
+ jobs = load_jobs()
338
+ if job_id in jobs:
339
+ log_path = Path(jobs[job_id]["log_file"])
340
+ timestamp = datetime.now().strftime("%H:%M:%S")
341
+ log_path.parent.mkdir(parents=True, exist_ok=True)
342
+ with open(log_path, "a", encoding="utf-8") as f:
343
+ f.write(f"[{timestamp}] {message}\n")
344
+
345
+
346
+ def get_jobs_table():
347
+ jobs = load_jobs()
348
+ rows = []
349
+ for job_id, job in sorted(jobs.items(), key=lambda x: x[1].get("created_at", ""), reverse=True):
350
+ duration = "-"
351
+ if job.get("started_at") and job.get("finished_at"):
352
+ try:
353
+ start = datetime.fromisoformat(job["started_at"])
354
+ end = datetime.fromisoformat(job["finished_at"])
355
+ duration = f"{(end - start).total_seconds() / 60:.1f}"
356
+ except Exception:
357
+ pass
358
+ if job["status"] == STATUS_DONE:
359
+ download = "βœ…"
360
+ elif job["status"] == STATUS_FAILED:
361
+ download = "❌"
362
+ elif job["status"] == STATUS_CONVERTING:
363
+ download = "⏳"
364
+ else:
365
+ download = "⏱️"
366
+ rows.append([job_id, job["model"], job["status"], duration, download])
367
+ return rows
368
+
369
+
370
+ def get_queue_info():
371
+ jobs = load_jobs()
372
+ waiting = sum(1 for j in jobs.values() if j["status"] == STATUS_WAITING)
373
+ converting = sum(1 for j in jobs.values() if j["status"] == STATUS_CONVERTING)
374
+ done = sum(1 for j in jobs.values() if j["status"] == STATUS_DONE)
375
+ failed = sum(1 for j in jobs.values() if j["status"] == STATUS_FAILED)
376
+ return f"**Fila:** {waiting} esperando Β· {converting} convertendo Β· {done} concluidos Β· {failed} falhas"
377
+
378
+
379
+ def poll_job(job_id: str):
380
+ if not job_id or not job_id.strip():
381
+ return "Digite um Job ID", None
382
+ job_id = job_id.strip()
383
+ jobs = load_jobs()
384
+ if job_id not in jobs:
385
+ return f"Job '{job_id}' nao encontrado", None
386
+ job = jobs[job_id]
387
+ if job["status"] == STATUS_DONE:
388
+ outputs = job.get("outputs", {})
389
+ out = outputs.get("saida")
390
+ if out and os.path.exists(out):
391
+ return "βœ… Job concluido!", out
392
+ job_dir = OUTPUTS_DIR / job_id
393
+ if job_dir.exists():
394
+ for ext in [".wav", ".flac", ".mp3", ".opus"]:
395
+ fallback = str(job_dir / f"saida{ext}")
396
+ if os.path.exists(fallback):
397
+ return "βœ… Job concluido!", fallback
398
+ return "βœ… Concluido, mas arquivo nao encontrado", None
399
+ elif job["status"] == STATUS_FAILED:
400
+ return f"❌ Falhou: {job.get('error', 'Erro desconhecido')}", None
401
+ elif job["status"] == STATUS_CONVERTING:
402
+ return "⏳ Ainda convertendo...", None
403
+ else:
404
+ return "⏱️ Na fila, aguardando...", None
405
+
406
+
407
+ # =============================================================================
408
+ # AUDIO UTILS
409
+ # =============================================================================
410
+ def ensure_wav(input_path: str, output_wav: str) -> str:
411
+ cmd = ["ffmpeg", "-y", "-i", input_path, "-acodec", "pcm_s16le", "-ar", str(SR_TARGET), "-ac", "2", output_wav]
412
+ result = subprocess.run(cmd, capture_output=True, text=True)
413
+ if result.returncode != 0:
414
+ raise RuntimeError(f"FFmpeg falhou: {result.stderr[:500]}")
415
+ return output_wav
416
+
417
+
418
+ def safe_write_wav(path: str, audio, sr: int):
419
+ sf = _ensure_soundfile()
420
+ import numpy as np
421
+ if audio.ndim == 1:
422
+ audio = np.stack([audio, audio], axis=-1)
423
+ elif audio.ndim == 2 and audio.shape[0] == 2 and audio.shape[1] > 2:
424
+ audio = audio.T
425
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
426
+ sf.write(path, audio, sr, format="WAV", subtype="PCM_16")
427
+
428
+
429
+ def get_audio_duration(wav_path: str) -> float:
430
+ """Duracao em segundos de um WAV garantido (nao engole erros)."""
431
+ sf = _ensure_soundfile()
432
+ info = sf.info(wav_path)
433
+ return float(info.duration)
434
+
435
+
436
+ def extract_audio_from_video(video_path: str, output_wav: str) -> str:
437
+ cmd = ["ffmpeg", "-y", "-i", video_path, "-vn", "-acodec", "pcm_s16le", "-ar", str(SR_TARGET), "-ac", "2", output_wav]
438
+ result = subprocess.run(cmd, capture_output=True, text=True)
439
+ if result.returncode != 0:
440
+ raise RuntimeError(f"FFmpeg falhou ao extrair audio do video: {result.stderr[:500]}")
441
+ return output_wav
442
+
443
+
444
+ def separate_audio_demucs(input_wav: str, output_dir: str, job_id: str) -> Tuple[str, str]:
445
+ _ensure_demucs()
446
+ from demucs.pretrained import get_model
447
+ from demucs.apply import apply_model
448
+ append_log(job_id, "Separando vocal/instrumental com Demucs...")
449
+ model = get_model("htdemucs")
450
+ model.cpu()
451
+ model.eval()
452
+ librosa = _ensure_librosa()
453
+ import numpy as np
454
+ wav_np, sr = librosa.load(input_wav, sr=44100, mono=False)
455
+ if wav_np.ndim == 1:
456
+ wav_np = np.stack([wav_np, wav_np])
457
+ elif wav_np.ndim == 2:
458
+ if wav_np.shape[0] > wav_np.shape[1]:
459
+ wav_np = wav_np.T
460
+ if wav_np.shape[0] > 2:
461
+ wav_np = wav_np[:2]
462
+ elif wav_np.shape[0] == 1:
463
+ wav_np = np.repeat(wav_np, 2, axis=0)
464
+ wav = torch.from_numpy(wav_np).float().unsqueeze(0)
465
+ with torch.no_grad():
466
+ sources = apply_model(model, wav, device="cpu", progress=False)
467
+ sources = sources[0]
468
+ source_names = model.sources
469
+ vocal_idx = source_names.index("vocals")
470
+ vocals = sources[vocal_idx].cpu().numpy()
471
+ instrumental = torch.zeros_like(sources[0])
472
+ for i, name in enumerate(source_names):
473
+ if name != "vocals":
474
+ instrumental += sources[i]
475
+ instrumental = instrumental.cpu().numpy()
476
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
477
+ vocal_path = os.path.join(output_dir, "entrada_acapella.wav")
478
+ inst_path = os.path.join(output_dir, "entrada_instrumental.wav")
479
+ safe_write_wav(vocal_path, vocals.T, 44100)
480
+ safe_write_wav(inst_path, instrumental.T, 44100)
481
+ vocal_48k = os.path.join(output_dir, "entrada_acapella_48k.wav")
482
+ inst_48k = os.path.join(output_dir, "entrada_instrumental_48k.wav")
483
+ ensure_wav(vocal_path, vocal_48k)
484
+ ensure_wav(inst_path, inst_48k)
485
+ return vocal_48k, inst_48k
486
+
487
+
488
+ def apply_reverb_wav(wav_path: str, room: float, damp: float, wet: float, job_id: str):
489
+ """Aplica reverb simples (convolucao com resposta ao impulso exponencial)
490
+ no WAV in-place. Qualquer falha aqui NUNCA derruba o job: loga e segue."""
491
  try:
492
+ import numpy as np
493
+ from scipy.signal import fftconvolve
494
+ sf = _ensure_soundfile()
495
+ audio, sr = sf.read(wav_path, always_2d=True)
496
+ if audio.shape[0] < sr // 4:
497
+ append_log(job_id, "[Reverb] Audio muito curto, pulando.")
498
+ return wav_path
499
+
500
+ room = float(np.clip(room, 0.0, 1.0))
501
+ damp = float(np.clip(damp, 0.0, 1.0))
502
+ wet = float(np.clip(wet, 0.0, 1.0))
503
+
504
+ decay_time = 0.05 + room * 0.95 # 0.05s .. 1.0s
505
+ n_ir = max(16, int(sr * decay_time))
506
+ t = np.arange(n_ir, dtype=np.float32) / sr
507
+ ir = np.exp(-6.0 * t / max(decay_time, 1e-3)).astype(np.float32)
508
+
509
+ if damp > 0.01: # suaviza a cauda (absorcao das altas frequencias)
510
+ k = max(2, int(1 + damp * 12))
511
+ kernel = np.ones(k, dtype=np.float32) / k
512
+ for _ in range(2):
513
+ ir = np.convolve(ir, kernel, mode="same")
514
+ ir *= np.random.default_rng(0).standard_normal(n_ir).astype(np.float32) * 0.5 + 0.5
515
+ ir = ir / (np.sqrt(np.sum(ir ** 2)) + 1e-8)
516
+
517
+ wet_sig = np.zeros_like(audio)
518
+ for ch in range(audio.shape[1]):
519
+ wet_sig[:, ch] = fftconvolve(audio[:, ch], ir, mode="full")[:audio.shape[0]]
520
+
521
+ mixed = (1.0 - wet) * audio + wet * wet_sig
522
+ peak = np.max(np.abs(mixed)) + 1e-8
523
+ if peak > 0.95:
524
+ mixed = mixed / peak * 0.95
525
+ sf.write(wav_path, mixed, sr, format="WAV", subtype="PCM_16")
526
+ append_log(job_id, f"[Reverb] Aplicado (room={room}, damp={damp}, wet={wet})")
527
+ return wav_path
528
+ except Exception as e:
529
+ append_log(job_id, f"[Reverb] Falhou (ignorado, sem reverb): {e}")
530
+ logger.warning("[Reverb] falhou: %s", e)
531
+ return wav_path
532
+
533
+
534
+ def mix_vocal_instrumental(vocal_path: str, inst_path: str, output_path: str, job_id: str):
535
+ append_log(job_id, "Mixando vocal + instrumental...")
536
+ librosa = _ensure_librosa()
537
+ import numpy as np
538
+ v, sr_v = librosa.load(vocal_path, sr=SR_TARGET, mono=True)
539
+ i, sr_i = librosa.load(inst_path, sr=SR_TARGET, mono=True)
540
+ min_len = min(len(v), len(i))
541
+ v = v[:min_len]
542
+ i = i[:min_len]
543
+ mixed = v * 0.85 + i * 1.0
544
+ mixed = mixed / (np.max(np.abs(mixed)) + 1e-8) * 0.95
545
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
546
+ safe_write_wav(output_path, mixed, SR_TARGET)
547
+ append_log(job_id, f"Mix final: {output_path}")
548
+ return output_path
549
+
550
+
551
+ # =============================================================================
552
+ # FORMAT CONVERSION β€” CONVERTE WAV PARA O FORMATO ESCOLHIDO
553
+ # =============================================================================
554
+ def get_format_ext(fmt: str) -> str:
555
+ mapping = {"WAV": ".wav", "FLAC": ".flac", "MP3": ".mp3", "OPUS": ".opus"}
556
+ return mapping.get(str(fmt).upper(), ".wav")
557
+
558
+
559
+ def get_zip_path(job_dir: Path, job_id: str, fmt: str) -> Path:
560
+ """Nome do ZIP SEMPRE deterministico β€” usado na criacao (process_job)
561
+ e na busca (load_downloads)."""
562
+ return job_dir / f"rvc_{job_id}_all_{str(fmt).upper()}.zip"
563
+
564
+
565
+ def convert_audio_format(input_wav: str, output_path: str, fmt: str, job_id: str):
566
+ """Converte um arquivo WAV para o formato escolhido (WAV/FLAC/MP3/OPUS)."""
567
+ fmt = str(fmt).upper()
568
+ append_log(job_id, f"[Format] Convertendo para {fmt}: {output_path}")
569
+
570
+ if not os.path.exists(input_wav):
571
+ raise FileNotFoundError(f"Arquivo de entrada nao encontrado: {input_wav}")
572
+
573
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
574
+
575
+ tmp_leftover = output_path + ".tmp_convert.wav"
576
+ if os.path.exists(tmp_leftover):
577
+ try:
578
+ os.remove(tmp_leftover)
579
+ except Exception:
580
+ pass
581
+
582
+ if os.path.abspath(input_wav) == os.path.abspath(output_path):
583
+ if fmt == "WAV":
584
+ append_log(job_id, f"[Format] Ja esta em WAV no caminho correto: {output_path}")
585
+ return output_path
586
+ shutil.copy2(input_wav, tmp_leftover)
587
+ input_wav = tmp_leftover
588
+ append_log(job_id, f"[Format] Criado temporario para evitar sobrescrita: {tmp_leftover}")
589
+
590
+ try:
591
+ if fmt == "WAV":
592
+ shutil.copy2(input_wav, output_path)
593
+ elif fmt == "FLAC":
594
+ cmd = ["ffmpeg", "-y", "-i", input_wav, "-acodec", "flac", "-compression_level", "5", output_path]
595
+ result = subprocess.run(cmd, capture_output=True, text=True)
596
+ if result.returncode != 0:
597
+ raise RuntimeError(f"FFmpeg FLAC falhou: {result.stderr[:300]}")
598
+ elif fmt == "MP3":
599
+ cmd = ["ffmpeg", "-y", "-i", input_wav, "-acodec", "libmp3lame", "-q:a", "2", output_path]
600
+ result = subprocess.run(cmd, capture_output=True, text=True)
601
+ if result.returncode != 0:
602
+ raise RuntimeError(f"FFmpeg MP3 falhou: {result.stderr[:300]}")
603
+ elif fmt == "OPUS":
604
+ cmd = ["ffmpeg", "-y", "-i", input_wav, "-acodec", "libopus", "-b:a", "128k", output_path]
605
+ result = subprocess.run(cmd, capture_output=True, text=True)
606
+ if result.returncode != 0:
607
+ raise RuntimeError(f"FFmpeg OPUS falhou: {result.stderr[:300]}")
608
+ else:
609
+ raise ValueError(f"Formato nao suportado: {fmt}")
610
+ finally:
611
+ if os.path.exists(tmp_leftover):
612
+ try:
613
+ os.remove(tmp_leftover)
614
+ except Exception:
615
+ pass
616
 
617
+ if not os.path.exists(output_path) or os.path.getsize(output_path) < 1024:
618
+ raise RuntimeError(f"Arquivo convertido nao foi criado ou esta vazio: {output_path}")
619
 
620
+ append_log(job_id, f"[Format] OK: {output_path} ({os.path.getsize(output_path) // 1024}KB)")
621
+ return output_path
 
 
 
622
 
623
 
624
+ # =============================================================================
625
+ # RVC INFERENCE β€” ultimate-rvc (SEM erro de HuBERT: a lib cuida de tudo)
626
+ # =============================================================================
627
+ _vc_instance = None
628
+ _vc_lock = threading.Lock()
629
 
630
 
631
+ def _get_vc():
632
+ global _vc_instance
633
+ if _vc_instance is None:
634
+ with _vc_lock:
635
+ if _vc_instance is None:
636
+ logger.info("[VC] Carregando VoiceConverter...")
637
+ VoiceConverter = _ensure_ultimate_rvc()
638
+ _vc_instance = VoiceConverter()
639
+ logger.info("[VC] VoiceConverter pronto.")
640
+ return _vc_instance
641
 
642
 
643
+ def _pth_and_index(model_name: str) -> tuple[str, str]:
644
+ d = MODELS_DIR / model_name
645
+ pths = list(d.glob("*.pth"))
646
+ idxs = list(d.glob("*.index"))
647
+ if not pths:
648
+ raise FileNotFoundError(f"Nenhum .pth encontrado em '{model_name}'")
649
+ return str(pths[0]), str(idxs[0]) if idxs else ""
650
+
651
+
652
+ def rvc_infer_ultimate(
653
+ model_name: str,
654
+ input_audio_path: str,
655
+ pitch: int,
656
+ f0_method: str,
657
+ output_path: str,
658
+ job_id: str,
659
+ index_rate: float = 0.75,
660
+ protect: float = 0.5,
661
+ filter_radius: int = 3,
662
+ volume_envelope: float = 0.25,
663
+ clean_audio: bool = False,
664
+ clean_strength: float = 0.5,
665
+ split_audio: bool = False,
666
+ autotune: bool = False,
667
+ autotune_strength: float = 1.0,
668
+ ):
669
+ append_log(job_id, f"[VC] Iniciando conversao com modelo: {model_name}")
670
+ model_path, index_path = _pth_and_index(model_name)
671
+ append_log(job_id, f"[VC] Modelo: {model_path}")
672
+ append_log(job_id, f"[VC] Index: {index_path or 'N/A'}")
673
+ vc = _get_vc()
674
+ append_log(job_id, f"[VC] Params: pitch={pitch}, f0={f0_method}, index_rate={index_rate}, protect={protect}")
675
  try:
676
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
677
+ vc.convert_audio(
678
+ audio_input_path=input_audio_path,
679
+ audio_output_path=output_path,
680
+ model_path=model_path,
681
+ index_path=index_path,
682
+ pitch=pitch,
683
+ f0_method=f0_method,
684
+ index_rate=index_rate,
685
+ volume_envelope=volume_envelope,
686
+ protect=protect,
687
+ split_audio=split_audio,
688
+ f0_autotune=autotune,
689
+ f0_autotune_strength=autotune_strength,
690
+ clean_audio=clean_audio,
691
+ clean_strength=clean_strength,
692
+ export_format="WAV",
693
+ filter_radius=filter_radius,
694
  )
695
+ append_log(job_id, f"[VC] Conversao concluida: {output_path}")
696
+ return output_path
697
+ except Exception as e:
698
+ append_log(job_id, f"[VC] ERRO na conversao: {e}")
699
+ traceback.print_exc()
700
+ raise
 
 
 
 
701
 
702
 
703
+ # =============================================================================
704
+ # WORKER β€” fila por queue, converte tudo para o formato escolhido
705
+ # =============================================================================
706
  _job_queue: queue.Queue = queue.Queue()
707
 
 
 
 
 
708
 
709
+ def process_job(job_id: str):
710
+ jobs = load_jobs()
711
+ if job_id not in jobs:
712
+ return
713
+ job = jobs[job_id]
714
+ try:
715
+ update_job_status(job_id, STATUS_CONVERTING)
716
+ append_log(job_id, "=" * 50)
717
+ append_log(job_id, f"Job {job_id} | Modelo: {job['model']} | Pitch: {job['pitch']}")
718
+
719
+ job_output_dir = OUTPUTS_DIR / job_id
720
+ job_output_dir.mkdir(parents=True, exist_ok=True)
721
+
722
+ model_dir = MODELS_DIR / job["model"]
723
+ if not model_dir.exists():
724
+ raise FileNotFoundError(f"Modelo '{job['model']}' nao encontrado")
725
+ if not list(model_dir.glob("*.pth")):
726
+ raise FileNotFoundError(f"Nenhum .pth em '{job['model']}'")
727
+
728
+ input_file = job.get("input_file")
729
+ if not input_file or not os.path.exists(input_file):
730
+ raise FileNotFoundError(f"Arquivo de entrada nao encontrado: {input_file}")
731
+
732
+ entrada_wav = str(job_output_dir / "entrada.wav")
733
+ ensure_wav(input_file, entrada_wav)
734
+ append_log(job_id, "Entrada WAV 48kHz OK")
735
+
736
+ # FIX: checagem de duracao DEPOIS do ffmpeg (WAV garantido) e sem
737
+ # engolir o ValueError β€” o bug antigo silenciava o limite.
738
+ duration = get_audio_duration(entrada_wav)
739
+ append_log(job_id, f"Duracao: {duration:.1f}s")
740
+ if duration > MAX_INPUT_DURATION:
741
+ raise ValueError(f"Audio muito longo: {duration:.0f}s (max: {MAX_INPUT_DURATION // 60} min)")
742
+
743
+ append_log(job_id, "Separando com Demucs...")
744
+ entrada_acapella, entrada_instrumental = separate_audio_demucs(entrada_wav, str(job_output_dir), job_id)
745
+ append_log(job_id, "Separacao OK")
746
+
747
+ saida_acapella_wav = str(job_output_dir / "saida_acapella.wav")
748
+ settings = job.get("settings", {})
749
+ output_fmt = settings.get("format", "WAV")
750
+ ext = get_format_ext(output_fmt)
751
+
752
+ rvc_infer_ultimate(
753
+ model_name=job["model"],
754
+ input_audio_path=entrada_acapella,
755
+ pitch=job["pitch"],
756
+ f0_method=job["f0_method"],
757
+ output_path=saida_acapella_wav,
758
+ job_id=job_id,
759
+ protect=settings.get("protect", 0.5),
760
+ index_rate=settings.get("index_rate", 0.75),
761
+ filter_radius=settings.get("filter_radius", 3),
762
+ volume_envelope=settings.get("vol_env", 0.25),
763
+ clean_audio=settings.get("clean", False),
764
+ clean_strength=settings.get("clean_strength", 0.5),
765
+ split_audio=settings.get("split", False),
766
+ autotune=settings.get("autotune", False),
767
+ autotune_strength=settings.get("autotune_strength", 1.0),
768
+ )
769
 
770
+ # Reverb aplicado de verdade na voz convertida (antes do mix)
771
+ if settings.get("reverb", False):
772
+ apply_reverb_wav(
773
+ saida_acapella_wav,
774
+ room=settings.get("reverb_room", 0.15),
775
+ damp=settings.get("reverb_damp", 0.7),
776
+ wet=settings.get("reverb_wet", 0.15),
777
+ job_id=job_id,
 
 
 
 
 
 
 
 
 
 
778
  )
779
 
780
+ saida_final_wav = str(job_output_dir / "saida.wav")
781
+ mix_vocal_instrumental(saida_acapella_wav, entrada_instrumental, saida_final_wav, job_id)
782
+
783
+ append_log(job_id, f"[Format] Convertendo 5 arquivos para {output_fmt}...")
784
+ internal_files = {
785
+ "entrada": entrada_wav,
786
+ "entrada_acapella": entrada_acapella,
787
+ "entrada_instrumental": entrada_instrumental,
788
+ "saida_acapella": saida_acapella_wav,
789
+ "saida": saida_final_wav,
790
+ }
791
+
792
+ outputs = {}
793
+ for label, src_path in internal_files.items():
794
+ dst_path = str(job_output_dir / f"{label}{ext}")
795
+ append_log(job_id, f"[Format] {label}: {src_path} -> {dst_path}")
796
+ outputs[label] = convert_audio_format(src_path, dst_path, output_fmt, job_id)
797
+
798
+ append_log(job_id, f"[Format] 5 arquivos processados para {output_fmt}")
799
+
800
+ for k, v in outputs.items():
801
+ if not os.path.exists(v) or os.path.getsize(v) < 1024:
802
+ raise RuntimeError(f"Output invalido: {k} -> {v}")
803
+
804
+ zip_path = get_zip_path(job_output_dir, job_id, output_fmt)
805
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
806
+ for k, v in outputs.items():
807
+ if os.path.exists(v):
808
+ zf.write(v, arcname=os.path.basename(v))
809
+ append_log(job_id, f"ZIP criado: {zip_path}")
810
+
811
+ update_job_status(job_id, STATUS_DONE, outputs=outputs)
812
+ append_log(job_id, "Conversao concluida!")
813
+
814
+ except Exception as e:
815
+ error_msg = str(e)
816
+ tb = traceback.format_exc()
817
+ append_log(job_id, f"ERRO: {error_msg}")
818
+ append_log(job_id, f"Traceback: {tb}")
819
+ update_job_status(job_id, STATUS_FAILED, error=error_msg)
820
+ finally:
821
+ if DEVICE == "cuda":
822
+ torch.cuda.empty_cache()
823
+
824
+
825
+ def _worker_loop() -> None:
826
+ while True:
827
+ job_id = None
828
+ try:
829
+ job_id = _job_queue.get()
830
+ if job_id is None:
831
+ break
832
+ process_job(job_id)
833
+ except Exception as e:
834
+ logger.error("Worker error: %s", e)
835
+ traceback.print_exc()
836
  finally:
837
+ if job_id is not None:
838
+ try:
839
+ _job_queue.task_done()
840
+ except ValueError:
841
+ pass
842
 
843
 
844
+ _worker_thread = threading.Thread(target=_worker_loop, daemon=True)
 
845
  _worker_thread.start()
846
  logger.info("Background worker started.")
847
 
848
 
849
+ # =============================================================================
850
+ # MODELS UI
851
+ # =============================================================================
852
+ def validate_zip(zip_path: str) -> tuple[bool, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
853
  try:
854
+ with zipfile.ZipFile(zip_path, "r") as zf:
855
+ files = zf.namelist()
856
+ has_pth = any(f.endswith(".pth") for f in files)
857
+ if not has_pth:
858
+ return False, "Falta .pth no ZIP"
859
+ return True, "ZIP valido"
860
+ except Exception as e:
861
+ return False, f"Erro no ZIP: {str(e)}"
862
+
863
+
864
+ def is_valid_rvc_checkpoint(ckpt) -> bool:
865
+ if not isinstance(ckpt, dict):
866
+ return False
867
+ for key, val in ckpt.items():
868
+ if isinstance(val, torch.Tensor):
869
+ return True
870
+ elif isinstance(val, dict):
871
+ for k2, v2 in val.items():
872
+ if isinstance(v2, torch.Tensor):
873
+ return True
874
+ return len(ckpt) > 0
875
+
876
+
877
+ def upload_model(zip_file, model_name: str):
878
+ import gradio as gr
879
+ if not zip_file:
880
+ return "❌ Nenhum arquivo selecionado", gr.update(choices=get_model_names()), refresh_models()
881
+ if not model_name or not model_name.strip():
882
+ model_name = Path(str(zip_file)).stem
883
+ model_name = model_name.strip()
884
+ valid, msg = validate_zip(zip_file)
885
+ if not valid:
886
+ return f"❌ {msg}", gr.update(choices=get_model_names()), refresh_models()
887
+ model_dir = MODELS_DIR / model_name
888
+ if model_dir.exists():
889
+ shutil.rmtree(model_dir)
890
+ model_dir.mkdir(parents=True, exist_ok=True)
891
  try:
892
+ with zipfile.ZipFile(zip_file, "r") as zf:
893
+ zf.extractall(str(model_dir))
894
+ for nested in list(model_dir.rglob("*.pth")) + list(model_dir.rglob("*.index")):
895
+ target = model_dir / nested.name
896
+ if nested != target:
897
+ shutil.move(str(nested), str(target))
898
+ except Exception as e:
899
+ return f"❌ Erro ao extrair: {str(e)}", gr.update(choices=get_model_names()), refresh_models()
900
+ try:
901
+ pth_files = list(model_dir.glob("*.pth"))
902
+ if pth_files:
903
+ ckpt = torch.load(str(pth_files[0]), map_location="cpu", weights_only=False)
904
+ if not is_valid_rvc_checkpoint(ckpt):
905
+ shutil.rmtree(model_dir)
906
+ return "❌ Checkpoint invalido (nao eh RVC)", gr.update(choices=get_model_names()), refresh_models()
907
+ except Exception as e:
908
+ shutil.rmtree(model_dir)
909
+ return f"❌ Erro no checkpoint: {str(e)}", gr.update(choices=get_model_names()), refresh_models()
910
+ choices = get_model_names()
911
+ return f"βœ… Modelo '{model_name}' enviado!", gr.update(choices=choices), refresh_models()
 
 
 
 
 
 
912
 
 
 
 
 
 
 
913
 
914
+ def refresh_models():
915
+ models = []
916
+ if MODELS_DIR.exists():
917
+ for d in sorted(MODELS_DIR.iterdir()):
918
+ if d.is_dir() and d.name != URVC_DIR.name:
919
+ pth = len(list(d.glob("*.pth")))
920
+ idx = len(list(d.glob("*.index")))
921
+ models.append([d.name, pth, idx, datetime.fromtimestamp(d.stat().st_ctime).strftime("%Y-%m-%d %H:%M")])
922
+ return models
923
+
924
+
925
+ def get_model_names():
926
+ models = []
927
+ if MODELS_DIR.exists():
928
+ for d in sorted(MODELS_DIR.iterdir()):
929
+ if d.is_dir() and d.name != URVC_DIR.name and list(d.glob("*.pth")):
930
+ models.append(d.name)
931
+ return models
932
+
933
+
934
+ def delete_model(model_name: str):
935
+ import gradio as gr
936
+ if not model_name or not model_name.strip():
937
+ return "❌ Digite o nome do modelo", gr.update(choices=get_model_names()), refresh_models()
938
+ model_dir = MODELS_DIR / model_name.strip()
939
+ if not model_dir.exists():
940
+ return "❌ Modelo nao encontrado", gr.update(choices=get_model_names()), refresh_models()
941
+ shutil.rmtree(model_dir)
942
+ choices = get_model_names()
943
+ return "βœ… Modelo excluido", gr.update(choices=choices), refresh_models()
944
 
 
945
 
946
+ def toggle_autotune(v):
947
+ import gradio as gr
948
+ return gr.update(visible=v)
 
 
 
949
 
950
 
951
+ def _refresh_models_ui():
952
+ import gradio as gr
953
+ return refresh_models(), gr.update(choices=get_model_names())
954
+
955
+
956
+ # =============================================================================
957
+ # JOBS UI
958
+ # =============================================================================
959
+ def submit_job(mic_file, upload_file, video_file, model_name: str, pitch: int, f0_method: str,
960
+ index_rate: float, protect: float, filter_radius: int,
961
+ vol_env: float, clean: bool, clean_strength: float,
962
+ split: bool, autotune: bool, autotune_strength: float,
963
+ fmt_radio: str,
964
+ reverb: bool, reverb_room: float, reverb_damp: float, reverb_wet: float):
965
+ import numpy as np
966
+ if not model_name:
967
+ return "❌ Escolha um modelo RVC", None
968
+
969
+ input_file = None
970
+ input_source = None
971
+ if mic_file is not None:
972
+ input_file = mic_file
973
+ input_source = "mic"
974
+ elif upload_file is not None:
975
+ input_file = upload_file
976
+ input_source = "upload"
977
+ elif video_file is not None:
978
+ input_file = video_file
979
+ input_source = "video"
980
+
981
+ if not input_file:
982
+ return "❌ Forneca um audio ou video", None
983
+
984
+ model_dir = MODELS_DIR / model_name
985
+ if not model_dir.exists():
986
+ return "❌ Modelo nao encontrado", None
987
+
988
+ job_id = f"rvc_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{np.random.randint(1000, 9999)}"
989
+ job_input_dir = JOBS_DIR / job_id
990
+ job_input_dir.mkdir(parents=True, exist_ok=True)
991
+ input_path = str(input_file)
992
+ if not os.path.exists(input_path):
993
+ return "❌ Arquivo nao encontrado", None
994
+
995
+ input_ext = os.path.splitext(input_path)[1] or ".wav"
996
+ saved_input = str(job_input_dir / f"input{input_ext}")
997
 
998
+ try:
999
+ if input_source == "video":
1000
+ audio_wav = str(job_input_dir / "input_audio.wav")
1001
+ extract_audio_from_video(input_path, audio_wav)
1002
+ saved_input = audio_wav
1003
+ else:
1004
+ shutil.copy2(input_path, saved_input)
1005
+ except Exception as e:
1006
+ return f"❌ Erro ao processar: {str(e)}", None
1007
+
1008
+ settings = {
1009
+ "index_rate": index_rate, "protect": protect, "filter_radius": filter_radius,
1010
+ "vol_env": vol_env, "clean": clean, "clean_strength": clean_strength,
1011
+ "split": split, "autotune": autotune, "autotune_strength": autotune_strength,
1012
+ "reverb": reverb, "reverb_room": reverb_room, "reverb_damp": reverb_damp, "reverb_wet": reverb_wet,
1013
+ "format": fmt_radio,
1014
+ }
1015
+ create_job(job_id, model_name, pitch, f0_method, saved_input, settings)
1016
+ append_log(job_id, f"Job criado. Entrada: {saved_input} ({os.path.getsize(saved_input)} bytes) | Formato: {fmt_radio}")
1017
+ _job_queue.put(job_id)
1018
+ return f"βœ… Job **{job_id}** criado! Acompanhe na aba πŸ“‹ Jobs.", None
1019
+
1020
+
1021
+ def delete_job(job_id: str):
1022
+ if not job_id or not job_id.strip():
1023
+ return "❌ Digite o ID", get_jobs_table()
1024
+ job_id = job_id.strip()
1025
+ jobs = load_jobs()
1026
+ if job_id not in jobs:
1027
+ return "❌ Job nao encontrado", get_jobs_table()
1028
+ for d in [JOBS_DIR / job_id, OUTPUTS_DIR / job_id]:
1029
+ if d.exists():
1030
+ shutil.rmtree(d)
1031
+ log_file = Path(jobs[job_id]["log_file"])
1032
+ if log_file.exists():
1033
+ log_file.unlink()
1034
+ del jobs[job_id]
1035
+ save_jobs(jobs)
1036
+ return "βœ… Job excluido", get_jobs_table()
1037
+
1038
+
1039
+ def view_logs(job_id: str):
1040
+ if not job_id or not job_id.strip():
1041
+ return "Digite um Job ID"
1042
+ job_id = job_id.strip()
1043
+ jobs = load_jobs()
1044
+ if job_id not in jobs:
1045
+ return "Job nao encontrado"
1046
+ log_file = Path(jobs[job_id]["log_file"])
1047
+ if not log_file.exists():
1048
+ return "Nenhum log ainda"
1049
+ with open(log_file, "r", encoding="utf-8") as f:
1050
+ return f.read()
1051
+
1052
+
1053
+ # =============================================================================
1054
+ # DOWNLOADS UI β€” robusto: revalida disco, gera formato sob demanda,
1055
+ # reconstrΓ³i ZIP, e suporta "reparo" de jobs falhos com arquivos parciais.
1056
+ # FIX PRINCIPAL: os caminhos servidos ao Gradio ficam sob BASE_DIR, que e
1057
+ # liberado via gr.set_static_paths + allowed_paths no launch (fim do arquivo).
1058
+ # =============================================================================
1059
+ def get_done_jobs():
1060
+ jobs = load_jobs()
1061
+ done = []
1062
+ for job_id, job in sorted(jobs.items(), key=lambda x: x[1].get("created_at", ""), reverse=True):
1063
+ if job["status"] == STATUS_DONE:
1064
+ done.append(job_id)
1065
+ continue
1066
+ if job["status"] == STATUS_FAILED:
1067
+ job_dir = OUTPUTS_DIR / job_id
1068
+ if job_dir.exists() and ((job_dir / "saida.wav").exists() or (job_dir / "saida_acapella.wav").exists()):
1069
+ done.append(f"{job_id} (reparo)")
1070
+ return done
1071
+
1072
+
1073
+ def load_downloads(job_id):
1074
+ """Retorna 7 valores: 5x audio_path, zip_path, status_msg."""
1075
+ EMPTY = None
1076
 
1077
+ try:
1078
+ if job_id is None or str(job_id).strip() == "":
1079
+ return EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, "Selecione um job no dropdown e clique em Carregar"
 
 
 
 
 
 
 
 
 
 
 
1080
 
1081
+ if isinstance(job_id, (list, tuple)) and len(job_id) > 0:
1082
+ job_id = job_id[0]
1083
 
1084
+ job_id = str(job_id).strip().replace(" (reparo)", "").strip()
1085
+ if not job_id:
1086
+ return EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, "Selecione um job no dropdown e clique em Carregar"
1087
 
1088
+ jobs = load_jobs()
1089
 
1090
+ if job_id not in jobs:
1091
+ return EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, f"Job '{job_id}' nao encontrado"
1092
+
1093
+ job = jobs[job_id]
1094
+ job_dir = OUTPUTS_DIR / job_id
1095
+ has_files = job_dir.exists() and (
1096
+ (job_dir / "saida.wav").exists() or (job_dir / "saida_acapella.wav").exists()
1097
+ )
1098
+ if job.get("status") != STATUS_DONE and not has_files:
1099
+ return EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, f"Job nao concluido: {job.get('status', 'desconhecido')}"
1100
+
1101
+ if not job_dir.exists():
1102
+ return EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, f"Diretorio do job nao encontrado: {job_dir}"
1103
+
1104
+ fmt = job.get("settings", {}).get("format", "WAV")
1105
+ ext = get_format_ext(fmt)
1106
+
1107
+ labels = ["entrada", "entrada_acapella", "entrada_instrumental", "saida_acapella", "saida"]
1108
+ wav_fallbacks = {
1109
+ "entrada": ["entrada.wav"],
1110
+ "entrada_acapella": ["entrada_acapella.wav", "entrada_acapella_48k.wav"],
1111
+ "entrada_instrumental": ["entrada_instrumental.wav", "entrada_instrumental_48k.wav"],
1112
+ "saida_acapella": ["saida_acapella.wav"],
1113
+ "saida": ["saida.wav"],
1114
+ }
1115
+
1116
+ def _ok(p):
1117
+ return bool(p) and os.path.exists(p) and os.path.getsize(p) > 1024
1118
+
1119
+ audio_outputs = []
1120
+ missing = []
1121
+ existing = []
1122
+ found_paths = {}
1123
+
1124
+ for label in labels:
1125
+ found = None
1126
+ primary = str(job_dir / f"{label}{ext}")
1127
+ fallbacks = [str(job_dir / w) for w in wav_fallbacks[label]]
1128
+
1129
+ # 1) arquivo ja no formato pedido
1130
+ if _ok(primary):
1131
+ found = primary
1132
+
1133
+ # 2) formato pedido ausente e nao eh WAV -> gerar sob demanda do WAV
1134
+ if not found and fmt != "WAV":
1135
+ for wav in fallbacks:
1136
+ if _ok(wav):
1137
+ try:
1138
+ append_log(job_id, f"[Downloads] Gerando {fmt} sob demanda para {label}...")
1139
+ found = convert_audio_format(wav, primary, fmt, job_id)
1140
+ except Exception as conv_e:
1141
+ logger.warning("[Downloads] Falha ao gerar %s para %s: %s", fmt, label, conv_e)
1142
+ found = wav
1143
+ append_log(job_id, f"[Downloads] Fallback para WAV em {label}: {conv_e}")
1144
+ break
1145
+
1146
+ # 3) fallback final para WAV (quando fmt==WAV, chega direto aqui)
1147
+ if not found:
1148
+ for cand in fallbacks:
1149
+ if _ok(cand):
1150
+ found = cand
1151
+ break
1152
+
1153
+ if found:
1154
+ audio_outputs.append(found)
1155
+ existing.append(f"{label} ({os.path.getsize(found) // 1024}KB)")
1156
+ found_paths[label] = found
1157
+ else:
1158
+ audio_outputs.append(EMPTY)
1159
+ missing.append(label)
1160
+ logger.warning("[Downloads] Nenhum arquivo encontrado para %s", label)
1161
+
1162
+ zip_path = get_zip_path(job_dir, job_id, fmt)
1163
+ zip_out = EMPTY
1164
+ try:
1165
+ needs_rebuild = True
1166
+ if zip_path.exists() and zip_path.stat().st_size > 1024:
1167
+ try:
1168
+ with zipfile.ZipFile(zip_path, "r") as zf:
1169
+ names_in_zip = set(zf.namelist())
1170
+ expected_names = {os.path.basename(p) for p in found_paths.values()}
1171
+ needs_rebuild = not expected_names.issubset(names_in_zip)
1172
+ except Exception:
1173
+ needs_rebuild = True
1174
+
1175
+ if needs_rebuild and found_paths:
1176
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
1177
+ for label, path in found_paths.items():
1178
+ zf.write(path, arcname=os.path.basename(path))
1179
+ logger.info("[Downloads] ZIP (re)criado: %s", zip_path)
1180
+
1181
+ if zip_path.exists() and zip_path.stat().st_size > 1024:
1182
+ zip_out = str(zip_path)
1183
+ except Exception as e:
1184
+ logger.warning("[Downloads] Erro ZIP: %s", e)
1185
+
1186
+ model_name = job.get("model", "?")
1187
+ pitch = job.get("pitch", "?")
1188
+
1189
+ if missing:
1190
+ status_msg = f"⚠️ {len(existing)}/5 arquivos OK | Faltando: {', '.join(missing)} | {model_name} | Pitch: {pitch} | Formato: {fmt}"
1191
+ else:
1192
+ size_mb = zip_path.stat().st_size / (1024 * 1024) if zip_out is not None else 0
1193
+ status_msg = f"βœ… 5 arquivos + ZIP ({size_mb:.1f} MB) | {model_name} | Pitch: {pitch} | Formato: {fmt}"
1194
 
1195
+ logger.info("[Downloads] Job %s: %s", job_id, status_msg)
1196
+ return tuple(audio_outputs) + (zip_out, status_msg)
1197
 
1198
+ except Exception as e:
1199
+ logger.error("[Downloads] Erro inesperado: %s", e, exc_info=True)
1200
+ return EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, f"❌ Erro interno: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
1201
 
1202
 
1203
+ def refresh_downloads():
1204
+ import gradio as gr
1205
+ choices = get_done_jobs()
1206
+ return gr.update(choices=choices, value=None)
1207
+
1208
+
1209
+ # =============================================================================
1210
+ # STARTUP
1211
+ # =============================================================================
1212
+ startup_status = ""
1213
+ _default_model = ""
1214
+ try:
1215
+ if os.environ.get("RVC_SKIP_STARTUP_DOWNLOADS") == "1":
1216
+ logger.info("RVC_SKIP_STARTUP_DOWNLOADS=1 β€” pulando downloads do startup.")
1217
+ _default_model = BUILTIN_MODELS[0]["name"]
1218
+ else:
1219
+ _default_model = _startup_downloads()
1220
+ startup_status = f"βœ… Ready Β· {DEVICE_LABEL}"
1221
+ except Exception as e:
1222
+ startup_status = f"⚠️ Startup issue: {e} · {DEVICE_LABEL}"
1223
+ logger.warning("Startup: %s", e)
1224
+
1225
+ initial_models = get_model_names()
1226
+ initial_value = _default_model if _default_model in initial_models else (initial_models[0] if initial_models else None)
1227
+
1228
+
1229
+ # =============================================================================
1230
+ # GRADIO UI
1231
+ # FIX DE DOWNLOADS: libera BASE_DIR para o servidor de arquivos do Gradio.
1232
+ # Sem isso, os players/botoes de download falham com erro de arquivo quando
1233
+ # o job esta pronto (o Gradio bloqueia caminhos fora do diretorio do app).
1234
+ # =============================================================================
1235
  import gradio as gr
1236
 
1237
+ gr.set_static_paths(paths=[str(BASE_DIR)])
 
 
 
 
 
 
1238
 
1239
+ with gr.Blocks(title="RVC Voice Conversion", delete_cache=(3600, 3600), css=CSS) as demo:
1240
 
1241
  gr.HTML(f"""
1242
+ <div id="header">
1243
+ <h1>πŸŽ™οΈ RVC Voice Conversion</h1>
1244
+ <p>Retrieval-Based Voice Conversion Β· record or upload Β· custom models Β· GPU/CPU auto</p>
1245
+ </div>
1246
+ <p id="status">{startup_status}</p>
1247
  """)
1248
 
1249
  with gr.Tabs():
1250
 
1251
  # ── TAB 1: Convert ────────────────────────────────────────────────────
1252
+ with gr.Tab("🎀 Convert"):
1253
  with gr.Row():
 
1254
  with gr.Column(scale=1):
1255
+ gr.Markdown("### πŸ”Š Input Audio / Video")
1256
  with gr.Tabs():
1257
  with gr.Tab("πŸŽ™οΈ Microphone"):
1258
  inp_mic = gr.Audio(
 
1266
  type="filepath",
1267
  label="Upload audio (wav / mp3 / flac / ogg …)",
1268
  )
1269
+ with gr.Tab("🎬 Upload Video"):
1270
+ inp_video = gr.Video(
1271
+ label="Upload video (mp4 / mov / avi / mkv …)",
1272
+ )
1273
 
1274
  gr.Markdown("### πŸ€– Model")
1275
  model_dd = gr.Dropdown(
1276
+ choices=initial_models,
1277
+ value=initial_value,
1278
  label="Active Voice Model",
1279
  interactive=True,
1280
  )
 
1322
  label="Reduction Strength",
1323
  )
1324
  with gr.Row():
1325
+ split_cb = gr.Checkbox(value=False, label="Split Long Audio")
1326
  autotune_cb = gr.Checkbox(value=False, label="Autotune")
1327
+ autotune_sl = gr.Slider(
1328
+ 0.0, 1.0, value=1.0, step=0.05,
1329
+ label="Autotune Strength",
1330
+ visible=False,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1331
  )
1332
+ autotune_cb.change(
1333
+ fn=toggle_autotune,
1334
+ inputs=autotune_cb,
1335
+ outputs=autotune_sl,
1336
  )
1337
+
1338
+ gr.Markdown("**πŸŽ›οΈ Reverb**")
1339
+ reverb_cb = gr.Checkbox(value=False, label="Enable Reverb")
1340
+ with gr.Group(visible=False) as reverb_group:
1341
+ reverb_room_sl = gr.Slider(
1342
+ 0.0, 1.0, value=0.15, step=0.05,
1343
+ label="Room Size",
1344
+ info="Larger = bigger sounding space",
1345
+ )
1346
+ reverb_damp_sl = gr.Slider(
1347
+ 0.0, 1.0, value=0.7, step=0.05,
1348
+ label="Damping",
1349
+ info="Higher = more absorption, less echo tail",
1350
  )
1351
+ reverb_wet_sl = gr.Slider(
1352
+ 0.0, 1.0, value=0.15, step=0.05,
1353
+ label="Wet Level",
1354
+ info="How much reverb is mixed in (0.15 = subtle)",
1355
+ )
1356
+ reverb_cb.change(
1357
+ fn=lambda v: gr.update(visible=v),
1358
+ inputs=reverb_cb,
1359
+ outputs=reverb_group,
1360
+ )
1361
 
1362
  fmt_radio = gr.Radio(
1363
+ choices=["WAV", "FLAC", "MP3", "OPUS"],
1364
  value="WAV",
1365
  label="Output Format",
1366
  info="OPUS = small file (~64 kbps, Telegram/Discord quality)",
1367
  )
1368
  convert_btn = gr.Button(
1369
+ "πŸš€ Convert Voice",
1370
  variant="primary",
1371
  )
1372
 
1373
  gr.Markdown("### 🎧 Output")
1374
  out_status = gr.Markdown(value="")
1375
+ out_audio = gr.Audio(label="Result (if still on page)", type="filepath", interactive=False)
1376
 
1377
  gr.Markdown("#### πŸ” Check Job Status")
1378
  with gr.Row():
1379
+ job_id_box = gr.Textbox(
1380
  label="Job ID",
1381
+ placeholder="e.g. rvc_20260709_195302_7848",
1382
  scale=3,
1383
  )
1384
  poll_btn = gr.Button("πŸ”„ Check", scale=1)
1385
  poll_status = gr.Markdown(value="")
1386
+ poll_audio = gr.Audio(label="Result", type="filepath", interactive=False)
1387
 
1388
  # ── TAB 2: Models ─────────────────────────────────────────────────────
1389
+ with gr.Tab("πŸ“¦ Models"):
1390
  gr.Markdown("""
1391
  ### Upload a Custom RVC Model
1392
  Provide a **`.zip`** containing:
 
1398
  """)
1399
  with gr.Row():
1400
  with gr.Column(scale=1):
1401
+ up_zip = gr.File(label="Model ZIP", file_types=[".zip"], type="filepath")
1402
+ up_name = gr.Textbox(
1403
  label="Model Name",
1404
  placeholder="Leave blank to use zip filename",
1405
  )
1406
+ up_btn = gr.Button("πŸ“€ Load Model", variant="primary")
1407
  up_status = gr.Textbox(label="Status", interactive=False, lines=2)
1408
  with gr.Column(scale=1):
1409
  gr.Markdown("### Loaded Models")
1410
  models_table = gr.Dataframe(
1411
+ headers=["Modelo", ".pth", ".index", "Data"],
1412
+ col_count=(4, "fixed"),
1413
+ value=refresh_models(),
1414
  interactive=False,
1415
  label="",
1416
  )
1417
+ refresh_btn = gr.Button("πŸ”„ Refresh")
1418
 
1419
  up_btn.click(
1420
  fn=upload_model,
 
1422
  outputs=[up_status, model_dd, models_table],
1423
  )
1424
  refresh_btn.click(
1425
+ fn=_refresh_models_ui,
1426
  outputs=[models_table, model_dd],
1427
  )
1428
 
1429
+ gr.Markdown("### πŸ—‘οΈ Delete Model")
1430
+ with gr.Row():
1431
+ del_model_name = gr.Textbox(label="Model Name", placeholder="Nome do modelo a excluir")
1432
+ del_model_btn = gr.Button("πŸ—‘οΈ Delete", variant="stop")
1433
+ del_model_status = gr.Textbox(label="Status", interactive=False)
1434
+ del_model_btn.click(
1435
+ fn=delete_model,
1436
+ inputs=[del_model_name],
1437
+ outputs=[del_model_status, model_dd, models_table],
1438
+ )
1439
+
1440
  # ── TAB 3: Jobs ───────────────────────────────────────────────────────
1441
  with gr.Tab("πŸ“‹ Jobs"):
1442
  gr.Markdown("All submitted jobs, newest first. Click **Refresh** to update.")
1443
+ queue_status = gr.Markdown(value=get_queue_info())
1444
  jobs_table = gr.Dataframe(
1445
+ headers=["Job ID", "Model", "Status", "Time", "Download"],
1446
+ col_count=(5, "fixed"),
1447
+ value=get_jobs_table(),
1448
+ interactive=False,
1449
+ wrap=True,
1450
+ datatype=["str", "str", "str", "str", "markdown"],
1451
+ )
1452
+ refresh_jobs_btn = gr.Button("πŸ”„ Refresh")
1453
+
1454
+ def _refresh_jobs():
1455
+ return get_queue_info(), get_jobs_table()
1456
+
1457
+ refresh_jobs_btn.click(fn=_refresh_jobs, outputs=[queue_status, jobs_table])
1458
+
1459
+ gr.Markdown("### πŸ” Manage Job")
1460
+ with gr.Row():
1461
+ with gr.Column():
1462
+ job_id_manage = gr.Textbox(label="Job ID", placeholder="ex: rvc_20260528_123456_7890")
1463
+ with gr.Column():
1464
+ view_logs_btn = gr.Button("πŸ“„ Ver Logs")
1465
+ delete_job_btn = gr.Button("πŸ—‘οΈ Excluir Job", variant="stop")
1466
+ logs_output = gr.Textbox(label="Logs", lines=20, interactive=False, max_lines=50)
1467
+ job_action_status = gr.Textbox(label="Status", interactive=False)
1468
+
1469
+ view_logs_btn.click(fn=view_logs, inputs=job_id_manage, outputs=logs_output)
1470
+ delete_job_btn.click(fn=delete_job, inputs=job_id_manage, outputs=[job_action_status, jobs_table])
1471
+
1472
+ # ── TAB 4: Downloads ──────────────────────────────────────────────────
1473
+ with gr.Tab("πŸ“₯ Downloads"):
1474
+ gr.Markdown("### 🎡 Baixar os 5 arquivos no formato escolhido + ZIP")
1475
+ gr.Markdown("**InstruΓ§Γ£o:** Selecione um job e clique em **Carregar** para ver os arquivos.")
1476
 
1477
+ with gr.Row():
1478
+ with gr.Column(scale=3):
1479
+ dl_job_dd = gr.Dropdown(
1480
+ choices=get_done_jobs(),
1481
+ value=None,
1482
+ label="Job Concluido",
1483
+ info="Apenas jobs com status βœ… Done",
1484
+ interactive=True,
1485
+ allow_custom_value=True,
1486
+ )
1487
+ with gr.Column(scale=1):
1488
+ dl_refresh_btn = gr.Button("πŸ”„ Atualizar Lista")
1489
+ dl_load_btn = gr.Button("πŸ“‚ Carregar Job", variant="primary")
1490
+
1491
+ dl_status = gr.Markdown(value="Aguardando seleΓ§Γ£o...")
1492
 
1493
+ gr.Markdown("---")
1494
+ gr.Markdown("### 🎧 Arquivos de Áudio")
1495
 
1496
+ with gr.Row():
1497
+ dl_wav1 = gr.Audio(label="🎡 Voz original com música", type="filepath", interactive=False)
1498
+ dl_wav2 = gr.Audio(label="🎀 Voz original isolada", type="filepath", interactive=False)
1499
+
1500
+ with gr.Row():
1501
+ dl_wav3 = gr.Audio(label="🎸 Instrumental original", type="filepath", interactive=False)
1502
+ dl_wav4 = gr.Audio(label="πŸŽ™οΈ RVC cantando acapella", type="filepath", interactive=False)
1503
+
1504
+ with gr.Row():
1505
+ dl_wav5 = gr.Audio(label="πŸ† RESULTADO FINAL", type="filepath", interactive=False)
1506
+
1507
+ gr.Markdown("---")
1508
+ gr.Markdown("### πŸ“¦ ZIP")
1509
+ dl_zip = gr.File(label="πŸ“₯ ZIP com todos os arquivos", type="filepath", interactive=False)
1510
+
1511
+ dl_refresh_btn.click(
1512
+ fn=refresh_downloads,
1513
+ outputs=[dl_job_dd],
1514
+ )
1515
+
1516
+ dl_load_btn.click(
1517
+ fn=load_downloads,
1518
+ inputs=[dl_job_dd],
1519
+ outputs=[dl_wav1, dl_wav2, dl_wav3, dl_wav4, dl_wav5, dl_zip, dl_status],
1520
+ )
1521
+
1522
+ dl_job_dd.change(
1523
+ fn=load_downloads,
1524
+ inputs=[dl_job_dd],
1525
+ outputs=[dl_wav1, dl_wav2, dl_wav3, dl_wav4, dl_wav5, dl_zip, dl_status],
1526
+ queue=False,
1527
+ )
1528
+
1529
+ # ── TAB 5: Help ───────────────────────────────────────────────────────
1530
+ with gr.Tab("ℹ️ Help"):
1531
  gr.Markdown(f"""
1532
+ ## Como funciona
1533
+ RVC (Retrieval-Based Voice Conversion) transforma uma gravacao de voz para soar
1534
+ como um locutor-alvo usando apenas o arquivo de modelo desse locutor.
1535
 
1536
  ---
1537
 
1538
+ ## Guia Rapido
1539
+ 1. Abra a aba **Convert**
1540
+ 2. **Grave** pelo microfone ou **envie** um arquivo de audio (wav, mp3, flac, ogg …)
1541
+ 3. Escolha um **modelo** no dropdown β€” 4 modelos pre-carregados no startup
1542
+ 4. Ajuste o **Pitch Shift** se necessario (ex: masculino β†’ feminino: tente +12 semitons)
1543
+ 5. Escolha o **Formato de SaΓ­da** (WAV/FLAC/MP3/OPUS)
1544
+ 6. Clique em **πŸš€ Convert Voice** e aguarde o resultado
1545
+ 7. Baixe tudo na aba **πŸ“₯ Downloads**
1546
 
1547
  ---
1548
 
1549
+ ## Modelos Pre-instalados
1550
+ | Modelo | Descricao |
1551
  |---|---|
1552
+ | **Vestia Zeta v1** | Hololive ID VTuber, modelo v1 |
1553
+ | **Vestia Zeta v2** | Hololive ID VTuber, modelo v2 (recomendado) |
1554
  | **Ayunda Risu** | Hololive ID VTuber |
1555
  | **Gawr Gura** | Hololive EN VTuber |
1556
 
1557
  ---
1558
 
1559
+ ## Metodos de Extracao de Pitch
1560
+ | Metodo | Velocidade | Qualidade | Melhor para |
1561
  |---|---|---|---|
1562
+ | **rmvpe** | ⚑⚑⚑ | β˜…β˜…β˜…β˜… | Uso geral (padrao) |
1563
+ | **fcpe** | ⚑⚑ | β˜…β˜…β˜…β˜… | Cantar |
1564
+ | **crepe** | ⚑ | β˜…β˜…β˜…β˜…β˜… | Maior qualidade, mais lento |
1565
+ | **crepe-tiny** | ⚑⚑ | β˜…β˜…β˜… | Baixo recurso |
1566
 
1567
  ---
1568
 
1569
+ ## Configuracoes Avancadas
1570
+ | Configuracao | Descricao |
1571
  |---|---|
1572
+ | **Index Rate** | Influencia do indice FAISS no timbre (0.75 recomendado) |
1573
+ | **Protect Consonants** | Previne artefatos em consoantes (0.5 = max) |
1574
+ | **Respiration Filter Radius** | Suaviza curva de pitch β€” maior reduz ruido de respiracao (0–7, padrao 3) |
1575
+ | **Volume Envelope Mix** | 0.25 = mistura natural Β· 1 = preserva loudness de entrada Β· 0 = saida do modelo |
1576
+ | **Noise Reduction** | Remove ruido de fundo antes da conversao |
1577
+ | **Split Long Audio** | Divide audio em chunks para gravacoes > 60 s |
1578
+ | **Autotune** | Ajusta pitch para nota musical mais proxima |
1579
+ | **Reverb** | Aplicado na voz convertida antes do mix final |
1580
 
1581
  ---
1582
 
1583
+ ## 5 Saidas Geradas (no formato escolhido)
1584
+ 1. **entrada** β€” Audio original completo (voz + musica)
1585
+ 2. **entrada_acapella** β€” Voz original isolada pelo Demucs
1586
+ 3. **entrada_instrumental** β€” Musica/instrumental isolado pelo Demucs
1587
+ 4. **saida_acapella** β€” Voz convertida pelo RVC (acapella)
1588
+ 5. **saida** β€” RESULTADO FINAL: voz RVC + instrumental original mixados
 
1589
 
1590
  ---
1591
 
 
1594
 
1595
  ---
1596
 
1597
+ ## Creditos
1598
  Engine: [Ultimate RVC](https://github.com/JackismyShephard/ultimate-rvc)
1599
  """)
1600
 
1601
+ # Wire convert button after all tabs
1602
+ def _submit_and_extract_id(mic_file, upload_file, video_file, model_name, pitch, f0_method,
1603
+ index_rate, protect, vol_env, clean, clean_strength,
1604
+ split, autotune, autotune_strength, filter_radius, fmt_radio,
1605
+ reverb, reverb_room, reverb_damp, reverb_wet):
1606
+ status, audio = submit_job(mic_file, upload_file, video_file, model_name, pitch, f0_method,
1607
+ index_rate, protect, filter_radius, vol_env,
1608
+ clean, clean_strength,
1609
+ split, autotune, autotune_strength,
1610
+ fmt_radio,
1611
+ reverb, reverb_room, reverb_damp, reverb_wet)
1612
+ match = re.search(r"rvc_\d{8}_\d{6}_\d{4}", status or "")
1613
  job_id = match.group(0) if match else ""
1614
  return status, audio, job_id, get_queue_info(), get_jobs_table()
1615
 
1616
  convert_btn.click(
1617
  fn=_submit_and_extract_id,
1618
  inputs=[
1619
+ inp_mic, inp_file, inp_video, model_dd,
1620
  pitch_sl, f0_radio,
1621
  index_rate_sl, protect_sl, vol_env_sl,
1622
  clean_cb, clean_sl,
 
1645
  demo.launch(
1646
  server_name="0.0.0.0",
1647
  server_port=int(os.getenv("PORT", 7860)),
1648
+ allowed_paths=[str(BASE_DIR)], # <- FIX: libera downloads de /mnt/agents/output
1649
  ssr_mode=False,
1650
+ )