@memef1f1y/dsh-plugin-tts 0.6.5 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +51 -51
  2. package/.github/ISSUE_TEMPLATE/config.yml +2 -2
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -28
  4. package/.github/pull_request_template.md +7 -7
  5. package/.github/workflows/contrib-triage.yml +66 -66
  6. package/.github/workflows/linked-issue.yml +21 -21
  7. package/.github/workflows/publish.yml +63 -0
  8. package/.github/workflows/stale.yml +30 -30
  9. package/.github/workflows/test.yml +33 -33
  10. package/.github/workflows/welcome.yml +22 -22
  11. package/ANALYSIS.md +205 -205
  12. package/PR-REVIEW.md +74 -74
  13. package/README.md +335 -335
  14. package/TODO.md +34 -34
  15. package/docs/HOST-CONTRACT.md +36 -36
  16. package/docs/RVC-GUIDE.md +390 -390
  17. package/docs/USER-GUIDE.md +389 -389
  18. package/docs/acceptance-checklist.md +147 -147
  19. package/docs/adaptive-chunked-playback.md +194 -194
  20. package/docs/macos-test-prompt.md +230 -230
  21. package/docs/windows-gpu-verify-prompt.md +151 -151
  22. package/lib/client.js +7164 -7164
  23. package/lib/index.mjs +3374 -3374
  24. package/package.json +51 -51
  25. package/rvc-server.py +564 -564
  26. package/tests/README.md +45 -45
  27. package/tests/client-load.mjs +406 -406
  28. package/tests/e2e-compact-index.mjs +86 -86
  29. package/tests/e2e-index-ab.mjs +138 -138
  30. package/tests/e2e-real-rvc.mjs +122 -122
  31. package/tests/i18n-keys.mjs +82 -82
  32. package/tests/package-runtime-patch.mjs +88 -88
  33. package/tests/rvc-server-live.mjs +230 -230
  34. package/tests/smoke.mjs +1284 -1284
  35. package/tools/gen-ui-preview.mjs +174 -174
  36. package/tools/mac-verify.sh +158 -158
  37. package/tools/package-runtime.py +214 -214
package/rvc-server.py CHANGED
@@ -1,564 +1,564 @@
1
- # rvc-server.py — local RVC voice-conversion HTTP service for dsh-plugin-tts.
2
- #
3
- # Runs on the user's machine with ANY RVC-Project WebUI installation (v1/v2).
4
- # It wraps the RVC inference core (infer/modules/vc) with a tiny JSON API:
5
- # GET /health -> service + loaded-model status
6
- # POST /load { model, index? } -> load a voice pack (model + optional index)
7
- # POST /convert{ audio_base64, params } -> convert an audio clip to the voice
8
- #
9
- # Protocol is JSON + base64 so no extra deps (python-multipart etc.) are needed.
10
- # Input audio may be wav or mp3 (mp3 is decoded via the RVC env's bundled ffmpeg).
11
- #
12
- # Usage (Windows, RVC WebUI with bundled runtime):
13
- # E:\AI\RVC20240604Nvidia\RVC20240604Nvidia\runtime\python.exe rvc-server.py \
14
- # --rvc-dir "E:\AI\RVC20240604Nvidia\RVC20240604Nvidia" \
15
- # --model "E:\...\assets\weights\azusa-test.pth" \
16
- # --index "E:\...\assets\indices\azusa-test_..._v2.index" \
17
- # --port 4892
18
- # Usage (macOS/Linux, portable or manually prepared env):
19
- # ~/rvc-work/venv/bin/python ~/rvc-work/rvc-server.py --port 4892
20
- # # or: <rvc-dir>/runtime/bin/python <rvc-dir>/rvc-server.py --port 4892
21
- import argparse
22
- import binascii
23
- import io
24
- import os
25
- import subprocess
26
- import sys
27
- import tempfile
28
- import threading
29
- import traceback
30
-
31
- BASE = os.path.dirname(os.path.abspath(__file__))
32
-
33
-
34
- def resolve_rvc_dir(value):
35
- if value:
36
- return os.path.abspath(value)
37
- if os.path.exists(os.path.join(BASE, "infer")):
38
- return BASE
39
- return None
40
-
41
-
42
- ap = argparse.ArgumentParser(description="dsh-plugin-tts RVC inference server")
43
- ap.add_argument("--port", type=int, default=4892)
44
- ap.add_argument("--host", default="127.0.0.1")
45
- ap.add_argument("--device", default="auto", help="cuda:0 / cpu / auto")
46
- ap.add_argument("--fp32", action="store_true", help="force fp32 (16xx GPUs)")
47
- ap.add_argument("--rvc-dir", default=None, help="RVC WebUI root (default: script dir if it is one)")
48
- ap.add_argument("--model", default=None, help="preload model (.pth) path")
49
- ap.add_argument("--index", default=None, help="preload index (.index) path")
50
- a = ap.parse_args()
51
-
52
- RVC_DIR = resolve_rvc_dir(a.rvc_dir)
53
- if not RVC_DIR or not os.path.exists(os.path.join(RVC_DIR, "infer")):
54
- print("ERROR: cannot locate RVC WebUI root (no infer/ dir). Pass --rvc-dir.", file=sys.stderr)
55
- sys.exit(1)
56
-
57
- os.chdir(RVC_DIR)
58
- sys.path.insert(0, RVC_DIR)
59
- os.environ.setdefault("weight_root", os.path.join(RVC_DIR, "assets", "weights"))
60
- os.environ.setdefault("index_root", os.path.join(RVC_DIR, "assets", "indices"))
61
- os.environ.setdefault("rmvpe_root", os.path.join(RVC_DIR, "assets", "rmvpe"))
62
-
63
- import numpy as np
64
- import soundfile as sf
65
- import torch
66
-
67
- # torch >= 2.6 defaults torch.load to weights_only=True, which breaks fairseq's
68
- # hubert checkpoint loading (contains non-allowlisted globals). All checkpoints
69
- # here are local + trusted (model .pth, hubert, rmvpe), so restore the legacy
70
- # behavior. Harmless on older torch.
71
- _orig_torch_load = torch.load
72
-
73
-
74
- def _tts_torch_load(*args, **kwargs):
75
- if "weights_only" not in kwargs:
76
- kwargs["weights_only"] = False
77
- return _orig_torch_load(*args, **kwargs)
78
-
79
-
80
- torch.load = _tts_torch_load
81
-
82
- from fastapi import FastAPI, HTTPException
83
- from fastapi.responses import JSONResponse
84
-
85
- from infer.modules.vc.modules import VC
86
- from infer.modules.vc.pipeline import Pipeline
87
- from infer.lib.infer_pack.models import (
88
- SynthesizerTrnMs256NSFsid,
89
- SynthesizerTrnMs256NSFsid_nono,
90
- SynthesizerTrnMs768NSFsid,
91
- SynthesizerTrnMs768NSFsid_nono,
92
- )
93
-
94
- # --- Index cache -----------------------------------------------------------
95
- # The RVC pipeline calls faiss.read_index() + index.reconstruct_n() on EVERY
96
- # convert, re-reading a ~400MB index file from disk each time. For chunked
97
- # progressive playback that cost is paid per chunk, so we cache the loaded
98
- # faiss Index objects by path (read_index is the dominant disk cost). The
99
- # cache is cleared on every /load (the index may have changed).
100
- try:
101
- import faiss as _faiss
102
-
103
- _orig_faiss_read = _faiss.read_index
104
- _faiss_index_cache = {}
105
- _faiss_lock = threading.Lock()
106
-
107
- def _cached_faiss_read(path, *args, **kwargs):
108
- key = os.path.abspath(str(path))
109
- with _faiss_lock:
110
- idx = _faiss_index_cache.get(key)
111
- if idx is None:
112
- idx = _orig_faiss_read(path, *args, **kwargs)
113
- _faiss_index_cache[key] = idx
114
- return idx
115
-
116
- _faiss.read_index = _cached_faiss_read
117
-
118
- def _clear_index_cache():
119
- with _faiss_lock:
120
- _faiss_index_cache.clear()
121
-
122
- INDEX_CACHE = True
123
- except Exception as _ie: # pragma: no cover - faiss always present in RVC env
124
- _clear_index_cache = lambda: None
125
- INDEX_CACHE = False
126
- print("faiss index cache disabled: %s" % _ie, file=sys.stderr)
127
-
128
-
129
- def resolve_device(dev):
130
- if dev and dev != "auto":
131
- return dev
132
- return "cuda:0" if torch.cuda.is_available() else "cpu"
133
-
134
-
135
- class Config:
136
- """Minimal config mirroring RVC configs/config.py device_config()."""
137
-
138
- def __init__(self, device, fp32=False):
139
- self.device = device
140
- if device.startswith("cuda"):
141
- self.is_half = True
142
- try:
143
- name = torch.cuda.get_device_name(int(device.split(":")[-1]))
144
- weak = (
145
- ("16" in name and "V100" not in name.upper())
146
- or "P40" in name.upper()
147
- or "P10" in name.upper()
148
- or "1060" in name
149
- or "1070" in name
150
- or "1080" in name
151
- )
152
- except Exception:
153
- weak = False
154
- if weak or fp32:
155
- self.is_half = False
156
- self.x_pad, self.x_query, self.x_center, self.x_max = 1, 6, 38, 41
157
- else:
158
- self.x_pad, self.x_query, self.x_center, self.x_max = 3, 10, 60, 65
159
- else:
160
- self.is_half = False
161
- self.x_pad, self.x_query, self.x_center, self.x_max = 1, 6, 38, 41
162
-
163
-
164
- config = Config(resolve_device(a.device), fp32=a.fp32)
165
- vc = VC(config)
166
- app = FastAPI(title="dsh-plugin-tts RVC server")
167
- _lock = threading.Lock()
168
- _state = {"model": None, "index": None}
169
-
170
-
171
- def load_model(model_path, index_path):
172
- model_path = os.path.abspath(str(model_path or "").strip().strip('"'))
173
- if not os.path.exists(model_path):
174
- raise HTTPException(400, "model not found: %s" % model_path)
175
- index_path = str(index_path or "").strip().strip('"')
176
- if index_path:
177
- index_path = os.path.abspath(index_path)
178
- if not os.path.exists(index_path):
179
- raise HTTPException(400, "index not found: %s" % index_path)
180
-
181
- cpt = torch.load(model_path, map_location="cpu")
182
- vc.cpt = cpt
183
- vc.tgt_sr = cpt["config"][-1]
184
- cpt["config"][-3] = cpt["weight"]["emb_g.weight"].shape[0]
185
- vc.if_f0 = cpt.get("f0", 1)
186
- vc.version = cpt.get("version", "v1")
187
-
188
- cls = {
189
- ("v1", 1): SynthesizerTrnMs256NSFsid,
190
- ("v1", 0): SynthesizerTrnMs256NSFsid_nono,
191
- ("v2", 1): SynthesizerTrnMs768NSFsid,
192
- ("v2", 0): SynthesizerTrnMs768NSFsid_nono,
193
- }.get((vc.version, vc.if_f0), SynthesizerTrnMs256NSFsid)
194
-
195
- vc.net_g = cls(*cpt["config"], is_half=config.is_half)
196
- del vc.net_g.enc_q
197
- vc.net_g.load_state_dict(cpt["weight"], strict=False)
198
- vc.net_g.eval().to(config.device)
199
- vc.net_g = vc.net_g.half() if config.is_half else vc.net_g.float()
200
- vc.pipeline = Pipeline(vc.tgt_sr, config)
201
- vc.hubert_model = None # lazy-loaded by vc_single
202
- vc.index_path = index_path if index_path else ""
203
-
204
- _state["model"] = model_path
205
- _state["index"] = vc.index_path
206
- print("Loaded model %s (v%s, f0=%s, sr=%s, half=%s)" % (os.path.basename(model_path), vc.version, vc.if_f0, vc.tgt_sr, config.is_half), flush=True)
207
-
208
-
209
- def decode_to_wav(data):
210
- """Return a wav file path for the input bytes; decodes mp3/etc via ffmpeg or PyAV.
211
-
212
- The Windows portable runtime ships ffmpeg.exe; macOS/Linux portable builds may
213
- not have a bundled ffmpeg, so we fall back to PyAV (already a Python dep in
214
- the RVC runtime) to decode and downmix to mono WAV.
215
- """
216
- if data[:4] == b"RIFF":
217
- fd, path = tempfile.mkstemp(suffix=".wav")
218
- with os.fdopen(fd, "wb") as f:
219
- f.write(data)
220
- return path
221
- src = tempfile.mktemp(suffix=".in")
222
- with open(src, "wb") as f:
223
- f.write(data)
224
- dst = tempfile.mktemp(suffix=".wav")
225
- ff = os.path.join(RVC_DIR, "ffmpeg.exe") if os.path.exists(os.path.join(RVC_DIR, "ffmpeg.exe")) else "ffmpeg"
226
- ffmpeg_err = b""
227
- try:
228
- r = subprocess.run([ff, "-y", "-i", src, "-ac", "1", dst], capture_output=True)
229
- if r.returncode == 0 and os.path.exists(dst):
230
- try:
231
- os.unlink(src)
232
- except OSError:
233
- pass
234
- return dst
235
- ffmpeg_err = r.stderr or b""
236
- except FileNotFoundError:
237
- ffmpeg_err = b"ffmpeg not found; trying PyAV fallback"
238
- except Exception as e:
239
- ffmpeg_err = str(e).encode("utf-8", "ignore")
240
- # PyAV fallback: self-contained for macOS/Linux portable builds and also
241
- # useful when the system has no ffmpeg in PATH.
242
- try:
243
- import av
244
- from av.audio.resampler import AudioResampler
245
- with av.open(src) as container:
246
- stream = container.streams.audio[0]
247
- rate = stream.rate or 44100
248
- with av.open(dst, "w", format="wav") as out:
249
- out_stream = out.add_stream("pcm_s16le", rate=rate)
250
- out_stream.layout = "mono"
251
- resampler = AudioResampler(format="s16", layout="mono", rate=rate)
252
- for frame in container.decode(stream):
253
- for rf in resampler.resample(frame):
254
- for packet in out_stream.encode(rf):
255
- out.mux(packet)
256
- for rf in resampler.resample(None):
257
- for packet in out_stream.encode(rf):
258
- out.mux(packet)
259
- for packet in out_stream.encode(None):
260
- out.mux(packet)
261
- try:
262
- os.unlink(src)
263
- except OSError:
264
- pass
265
- return dst
266
- except Exception as e:
267
- try:
268
- os.unlink(src)
269
- except OSError:
270
- pass
271
- try:
272
- os.unlink(dst)
273
- except OSError:
274
- pass
275
- detail = str(e)[-300:]
276
- if ffmpeg_err:
277
- detail = "%s | PyAV: %s" % (ffmpeg_err.decode("utf-8", "ignore")[-300:], detail)
278
- raise HTTPException(400, "audio decode failed: %s" % detail)
279
-
280
-
281
- def b64(data):
282
- import base64
283
- return base64.b64encode(data).decode("ascii")
284
-
285
-
286
- def unb64(s):
287
- import base64
288
- try:
289
- return base64.b64decode(str(s), validate=False)
290
- except (binascii.Error, ValueError) as e:
291
- raise HTTPException(400, "invalid audio_base64: %s" % e)
292
-
293
-
294
- def trim_edges(audio, sr, lead_ms=250, tail_ms=700, lead_keep_ms=20, tail_keep_ms=120, thr=0.003, fade_ms=5):
295
- """Trim per-chunk edge silence so chunked playback joins seamlessly.
296
-
297
- Edge TTS and the RVC pipeline both leave padding silence at clip edges
298
- (measured: ~140ms lead, ~540ms tail per chunk). Played back-to-back those
299
- add up to a ~680ms dead gap at every chunk boundary. Trim near-silence
300
- below `thr` (default -50dB), keep a short natural breath at each edge, and
301
- apply a tiny fade to avoid clicks.
302
-
303
- vc_single returns INT16 audio; integer dtypes are normalized to float
304
- [-1, 1] here (sf.write expects float in [-1,1] and would otherwise clip).
305
- Returns a float32 array in [-1, 1].
306
- """
307
- x = np.asarray(audio)
308
- if np.issubdtype(x.dtype, np.integer):
309
- info = np.iinfo(x.dtype)
310
- x = x.astype(np.float32) / float(info.max)
311
- else:
312
- x = x.astype(np.float32)
313
- n = len(x)
314
- if n < int(sr * 0.1):
315
- return x
316
- absx = np.abs(x)
317
- lead_n = min(int(sr * lead_ms / 1000), n)
318
- tail_n = min(int(sr * tail_ms / 1000), n)
319
- lead = 0
320
- while lead < lead_n and absx[lead] < thr:
321
- lead += 1
322
- tail = 0
323
- while tail < tail_n and absx[n - 1 - tail] < thr:
324
- tail += 1
325
- lead = max(0, lead - int(sr * lead_keep_ms / 1000))
326
- tail = max(0, tail - int(sr * tail_keep_ms / 1000))
327
- if lead == 0 and tail == 0:
328
- return x
329
- out = np.copy(x[lead: n - tail])
330
- fade_n = int(sr * fade_ms / 1000)
331
- if fade_n > 0 and len(out) > 2 * fade_n:
332
- ramp = np.linspace(0.0, 1.0, fade_n, dtype=np.float32)
333
- out[:fade_n] *= ramp
334
- out[-fade_n:] *= ramp[::-1]
335
- return out
336
-
337
-
338
- @app.get("/health")
339
- def health():
340
- gpu_name = None
341
- vram_gb = None
342
- if config.device.startswith("cuda") and torch.cuda.is_available():
343
- try:
344
- dev_idx = int(config.device.split(":")[-1])
345
- gpu_name = torch.cuda.get_device_name(dev_idx)
346
- vram_gb = round(torch.cuda.get_device_properties(dev_idx).total_memory / (1024 ** 3), 1)
347
- except Exception:
348
- pass
349
- return {
350
- "ok": True,
351
- "model_loaded": _state["model"] is not None,
352
- "model": os.path.basename(_state["model"]) if _state["model"] else None,
353
- "index": os.path.basename(_state["index"]) if _state["index"] else None,
354
- "device": config.device,
355
- "is_half": config.is_half,
356
- "cuda_available": torch.cuda.is_available(),
357
- "gpu_name": gpu_name,
358
- "vram_gb": vram_gb,
359
- "index_cache": INDEX_CACHE,
360
- "index_cache_entries": len(_faiss_index_cache) if INDEX_CACHE else 0,
361
- }
362
-
363
-
364
- def scan_files(kind):
365
- """Discover local model/index files so the web UI can pick them by path."""
366
- exts = {".pth"} if kind == "pth" else {".index"}
367
- roots = [
368
- os.environ.get("weight_root", os.path.join(RVC_DIR, "assets", "weights")),
369
- os.environ.get("index_root", os.path.join(RVC_DIR, "assets", "indices")),
370
- ]
371
- logs = os.path.join(RVC_DIR, "logs")
372
- if os.path.isdir(logs):
373
- roots.append(logs)
374
- # the plugin's voice-pack install dir, so downloaded packs show up in the
375
- # browse picker (installed files are named <packId>.pth / <packId>.index)
376
- pack_root = os.path.expanduser(os.path.join("~", ".dsh", "tts-rvc", "packs"))
377
- if os.path.isdir(pack_root):
378
- roots.append(pack_root)
379
- out = []
380
- seen = set()
381
- for root in roots:
382
- if not os.path.isdir(root):
383
- continue
384
- for dirpath, _, names in os.walk(root):
385
- for n in names:
386
- if os.path.splitext(n)[1].lower() in exts:
387
- p = os.path.abspath(os.path.join(dirpath, n))
388
- if p in seen:
389
- continue
390
- seen.add(p)
391
- try:
392
- size = os.path.getsize(p)
393
- except OSError:
394
- size = 0
395
- out.append({"name": n, "path": p, "size": size})
396
- out.sort(key=lambda f: f["name"].lower())
397
- return out
398
-
399
-
400
- @app.get("/files")
401
- def files(kind: str = "pth"):
402
- if kind not in ("pth", "index"):
403
- raise HTTPException(400, "kind must be pth|index")
404
- return {"ok": True, "kind": kind, "files": scan_files(kind)}
405
-
406
-
407
- @app.post("/load")
408
- def load(payload: dict):
409
- with _lock:
410
- load_model(payload.get("model"), payload.get("index"))
411
- _clear_index_cache() # index may have changed; drop cached faiss objects
412
- return {"ok": True, "model": _state["model"], "index": _state["index"]}
413
-
414
-
415
- @app.post("/compact-index")
416
- def compact_index(payload: dict):
417
- """Build a compact index from a big trained index.
418
-
419
- RVC indexes (IVF, ~hundreds of MB) are sub-sampled into a small exact flat
420
- index with the SAME metric as the source (RVC trains with index_factory
421
- which defaults to L2; the pipeline's square(1/score) weighting is built for
422
- L2, so a wrong metric inverts the neighbor ranking). Flat search over the
423
- sample is exact over the sample. Output is written next to the source (or
424
- into out_dir) and never overwrites it.
425
- """
426
- src = str(payload.get("index") or "").strip().strip('"')
427
- if not src or not os.path.exists(src):
428
- raise HTTPException(400, "index not found: %s" % src)
429
- try:
430
- target = int(payload.get("target_vectors", 10000))
431
- except (TypeError, ValueError):
432
- target = 10000
433
- target = max(500, min(target, 200000))
434
- out_dir = str(payload.get("out_dir") or "").strip().strip('"')
435
- if not out_dir:
436
- out_dir = os.path.dirname(os.path.abspath(src))
437
- out_dir = os.path.abspath(out_dir)
438
- with _lock:
439
- try:
440
- idx = _faiss.read_index(src)
441
- ntotal = int(idx.ntotal)
442
- d = int(idx.d)
443
- try:
444
- metric = int(getattr(idx, "metric_type", _faiss.METRIC_L2))
445
- except Exception:
446
- metric = _faiss.METRIC_L2
447
- src_size = os.path.getsize(src)
448
- if ntotal <= target:
449
- return {
450
- "ok": True, "already_small": True,
451
- "path": os.path.abspath(src),
452
- "vectors": ntotal, "source_vectors": ntotal,
453
- "size": src_size, "source_size": src_size,
454
- "reduction_pct": 0.0,
455
- }
456
- big = idx.reconstruct_n(0, ntotal)
457
- rng = np.random.RandomState(42)
458
- sample = np.ascontiguousarray(big[rng.permutation(ntotal)[:target]], dtype="float32")
459
- new_idx = (
460
- _faiss.IndexFlatIP(d) if metric == _faiss.METRIC_INNER_PRODUCT
461
- else _faiss.IndexFlatL2(d)
462
- )
463
- new_idx.add(sample)
464
- stem = os.path.splitext(os.path.basename(src))[0]
465
- out_path = os.path.join(out_dir, "%s_compact_%d.index" % (stem, target))
466
- _faiss.write_index(new_idx, out_path)
467
- out_size = os.path.getsize(out_path)
468
- return {
469
- "ok": True, "path": out_path, "size": out_size,
470
- "vectors": target, "source_vectors": ntotal,
471
- "source_size": src_size,
472
- "reduction_pct": round((1 - out_size / src_size) * 100, 1),
473
- }
474
- except HTTPException:
475
- raise
476
- except Exception as e:
477
- return JSONResponse(
478
- status_code=500,
479
- content={"code": "compact_index_error",
480
- "message": "%s\n%s" % (e, traceback.format_exc()[-600:])},
481
- )
482
-
483
-
484
- @app.post("/convert")
485
- def convert(payload: dict):
486
- if _state["model"] is None:
487
- raise HTTPException(400, "no model loaded; call /load first")
488
- audio_b64 = payload.get("audio_base64")
489
- if not audio_b64:
490
- raise HTTPException(400, "audio_base64 required")
491
- p = payload.get("params") or {}
492
- spk_id = int(p.get("spk_id", 0))
493
- f0_file = str(p.get("f0_file") or "").strip().strip('"')
494
- f0_up_key = int(p.get("f0_up_key", 0))
495
- f0_method = str(p.get("f0_method", "rmvpe"))
496
- # validate early so a bad f0_method never reaches infer (which crashes with
497
- # an UnboundLocalError and a 500). Keep the RVC f0 method set.
498
- _F0_METHODS = {"rmvpe", "pm", "harvest", "crepe", "rmvpe+", "mangio-crepe", "fcpe"}
499
- if f0_method not in _F0_METHODS:
500
- raise HTTPException(400, "unknown f0_method: %r (expected one of %s)" % (f0_method, sorted(_F0_METHODS)))
501
- index_rate = float(p.get("index_rate", 0.75))
502
- filter_radius = int(p.get("filter_radius", 3))
503
- resample_sr = int(p.get("resample_sr", 40000))
504
- rms_mix_rate = float(p.get("rms_mix_rate", 0.25))
505
- protect = float(p.get("protect", 0.33))
506
-
507
- with _lock:
508
- try:
509
- data = unb64(audio_b64)
510
- wav_path = decode_to_wav(data)
511
- try:
512
- # Validate the decoded audio is actually decodable + has samples.
513
- # Without this, an empty/corrupt wav falls through to infer's
514
- # load_audio which dies with a RuntimeError -> 500. Treat it as
515
- # a client error (400) instead.
516
- try:
517
- with sf.SoundFile(wav_path) as s:
518
- frames = s.frames
519
- except Exception:
520
- frames = 0
521
- if frames <= 0:
522
- raise HTTPException(400, "audio decode failed: empty or unreadable audio")
523
- # pipeline expects f0_file to be an object with a `.name` path
524
- f0_handle = (
525
- type("F0File", (), {"name": os.path.abspath(f0_file)})()
526
- if f0_file
527
- else None
528
- )
529
- # file_index="" + file_index2=<index> bypasses the WebUI's
530
- # "trained"->"added" auto-rewrite; empty index = index-free mode.
531
- info, opt = vc.vc_single(
532
- spk_id, wav_path, f0_up_key, f0_handle, f0_method,
533
- "", _state["index"], index_rate,
534
- filter_radius, resample_sr, rms_mix_rate, protect,
535
- )
536
- finally:
537
- try:
538
- os.unlink(wav_path)
539
- except OSError:
540
- pass
541
- if opt is None or opt[0] is None:
542
- raise HTTPException(500, "inference failed: %s" % str(info)[-500:])
543
- tgt_sr, audio = opt
544
- audio = trim_edges(audio, int(tgt_sr))
545
- buf = io.BytesIO()
546
- sf.write(buf, audio, int(tgt_sr), format="wav")
547
- return {"audio_base64": b64(buf.getvalue()), "sample_rate": int(tgt_sr)}
548
- except HTTPException:
549
- raise
550
- except Exception as e:
551
- return JSONResponse(
552
- status_code=500,
553
- content={"code": "inference_error", "message": "%s\n%s" % (e, traceback.format_exc()[-600:])},
554
- )
555
-
556
-
557
- if a.model:
558
- print("Preloading model...", flush=True)
559
- load_model(a.model, a.index or "")
560
-
561
- if __name__ == "__main__":
562
- import uvicorn
563
- print("dsh-plugin-tts RVC server: http://%s:%d (device=%s, half=%s)" % (a.host, a.port, config.device, config.is_half), flush=True)
564
- uvicorn.run(app, host=a.host, port=a.port, log_level="warning")
1
+ # rvc-server.py — local RVC voice-conversion HTTP service for dsh-plugin-tts.
2
+ #
3
+ # Runs on the user's machine with ANY RVC-Project WebUI installation (v1/v2).
4
+ # It wraps the RVC inference core (infer/modules/vc) with a tiny JSON API:
5
+ # GET /health -> service + loaded-model status
6
+ # POST /load { model, index? } -> load a voice pack (model + optional index)
7
+ # POST /convert{ audio_base64, params } -> convert an audio clip to the voice
8
+ #
9
+ # Protocol is JSON + base64 so no extra deps (python-multipart etc.) are needed.
10
+ # Input audio may be wav or mp3 (mp3 is decoded via the RVC env's bundled ffmpeg).
11
+ #
12
+ # Usage (Windows, RVC WebUI with bundled runtime):
13
+ # E:\AI\RVC20240604Nvidia\RVC20240604Nvidia\runtime\python.exe rvc-server.py \
14
+ # --rvc-dir "E:\AI\RVC20240604Nvidia\RVC20240604Nvidia" \
15
+ # --model "E:\...\assets\weights\azusa-test.pth" \
16
+ # --index "E:\...\assets\indices\azusa-test_..._v2.index" \
17
+ # --port 4892
18
+ # Usage (macOS/Linux, portable or manually prepared env):
19
+ # ~/rvc-work/venv/bin/python ~/rvc-work/rvc-server.py --port 4892
20
+ # # or: <rvc-dir>/runtime/bin/python <rvc-dir>/rvc-server.py --port 4892
21
+ import argparse
22
+ import binascii
23
+ import io
24
+ import os
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ import threading
29
+ import traceback
30
+
31
+ BASE = os.path.dirname(os.path.abspath(__file__))
32
+
33
+
34
+ def resolve_rvc_dir(value):
35
+ if value:
36
+ return os.path.abspath(value)
37
+ if os.path.exists(os.path.join(BASE, "infer")):
38
+ return BASE
39
+ return None
40
+
41
+
42
+ ap = argparse.ArgumentParser(description="dsh-plugin-tts RVC inference server")
43
+ ap.add_argument("--port", type=int, default=4892)
44
+ ap.add_argument("--host", default="127.0.0.1")
45
+ ap.add_argument("--device", default="auto", help="cuda:0 / cpu / auto")
46
+ ap.add_argument("--fp32", action="store_true", help="force fp32 (16xx GPUs)")
47
+ ap.add_argument("--rvc-dir", default=None, help="RVC WebUI root (default: script dir if it is one)")
48
+ ap.add_argument("--model", default=None, help="preload model (.pth) path")
49
+ ap.add_argument("--index", default=None, help="preload index (.index) path")
50
+ a = ap.parse_args()
51
+
52
+ RVC_DIR = resolve_rvc_dir(a.rvc_dir)
53
+ if not RVC_DIR or not os.path.exists(os.path.join(RVC_DIR, "infer")):
54
+ print("ERROR: cannot locate RVC WebUI root (no infer/ dir). Pass --rvc-dir.", file=sys.stderr)
55
+ sys.exit(1)
56
+
57
+ os.chdir(RVC_DIR)
58
+ sys.path.insert(0, RVC_DIR)
59
+ os.environ.setdefault("weight_root", os.path.join(RVC_DIR, "assets", "weights"))
60
+ os.environ.setdefault("index_root", os.path.join(RVC_DIR, "assets", "indices"))
61
+ os.environ.setdefault("rmvpe_root", os.path.join(RVC_DIR, "assets", "rmvpe"))
62
+
63
+ import numpy as np
64
+ import soundfile as sf
65
+ import torch
66
+
67
+ # torch >= 2.6 defaults torch.load to weights_only=True, which breaks fairseq's
68
+ # hubert checkpoint loading (contains non-allowlisted globals). All checkpoints
69
+ # here are local + trusted (model .pth, hubert, rmvpe), so restore the legacy
70
+ # behavior. Harmless on older torch.
71
+ _orig_torch_load = torch.load
72
+
73
+
74
+ def _tts_torch_load(*args, **kwargs):
75
+ if "weights_only" not in kwargs:
76
+ kwargs["weights_only"] = False
77
+ return _orig_torch_load(*args, **kwargs)
78
+
79
+
80
+ torch.load = _tts_torch_load
81
+
82
+ from fastapi import FastAPI, HTTPException
83
+ from fastapi.responses import JSONResponse
84
+
85
+ from infer.modules.vc.modules import VC
86
+ from infer.modules.vc.pipeline import Pipeline
87
+ from infer.lib.infer_pack.models import (
88
+ SynthesizerTrnMs256NSFsid,
89
+ SynthesizerTrnMs256NSFsid_nono,
90
+ SynthesizerTrnMs768NSFsid,
91
+ SynthesizerTrnMs768NSFsid_nono,
92
+ )
93
+
94
+ # --- Index cache -----------------------------------------------------------
95
+ # The RVC pipeline calls faiss.read_index() + index.reconstruct_n() on EVERY
96
+ # convert, re-reading a ~400MB index file from disk each time. For chunked
97
+ # progressive playback that cost is paid per chunk, so we cache the loaded
98
+ # faiss Index objects by path (read_index is the dominant disk cost). The
99
+ # cache is cleared on every /load (the index may have changed).
100
+ try:
101
+ import faiss as _faiss
102
+
103
+ _orig_faiss_read = _faiss.read_index
104
+ _faiss_index_cache = {}
105
+ _faiss_lock = threading.Lock()
106
+
107
+ def _cached_faiss_read(path, *args, **kwargs):
108
+ key = os.path.abspath(str(path))
109
+ with _faiss_lock:
110
+ idx = _faiss_index_cache.get(key)
111
+ if idx is None:
112
+ idx = _orig_faiss_read(path, *args, **kwargs)
113
+ _faiss_index_cache[key] = idx
114
+ return idx
115
+
116
+ _faiss.read_index = _cached_faiss_read
117
+
118
+ def _clear_index_cache():
119
+ with _faiss_lock:
120
+ _faiss_index_cache.clear()
121
+
122
+ INDEX_CACHE = True
123
+ except Exception as _ie: # pragma: no cover - faiss always present in RVC env
124
+ _clear_index_cache = lambda: None
125
+ INDEX_CACHE = False
126
+ print("faiss index cache disabled: %s" % _ie, file=sys.stderr)
127
+
128
+
129
+ def resolve_device(dev):
130
+ if dev and dev != "auto":
131
+ return dev
132
+ return "cuda:0" if torch.cuda.is_available() else "cpu"
133
+
134
+
135
+ class Config:
136
+ """Minimal config mirroring RVC configs/config.py device_config()."""
137
+
138
+ def __init__(self, device, fp32=False):
139
+ self.device = device
140
+ if device.startswith("cuda"):
141
+ self.is_half = True
142
+ try:
143
+ name = torch.cuda.get_device_name(int(device.split(":")[-1]))
144
+ weak = (
145
+ ("16" in name and "V100" not in name.upper())
146
+ or "P40" in name.upper()
147
+ or "P10" in name.upper()
148
+ or "1060" in name
149
+ or "1070" in name
150
+ or "1080" in name
151
+ )
152
+ except Exception:
153
+ weak = False
154
+ if weak or fp32:
155
+ self.is_half = False
156
+ self.x_pad, self.x_query, self.x_center, self.x_max = 1, 6, 38, 41
157
+ else:
158
+ self.x_pad, self.x_query, self.x_center, self.x_max = 3, 10, 60, 65
159
+ else:
160
+ self.is_half = False
161
+ self.x_pad, self.x_query, self.x_center, self.x_max = 1, 6, 38, 41
162
+
163
+
164
+ config = Config(resolve_device(a.device), fp32=a.fp32)
165
+ vc = VC(config)
166
+ app = FastAPI(title="dsh-plugin-tts RVC server")
167
+ _lock = threading.Lock()
168
+ _state = {"model": None, "index": None}
169
+
170
+
171
+ def load_model(model_path, index_path):
172
+ model_path = os.path.abspath(str(model_path or "").strip().strip('"'))
173
+ if not os.path.exists(model_path):
174
+ raise HTTPException(400, "model not found: %s" % model_path)
175
+ index_path = str(index_path or "").strip().strip('"')
176
+ if index_path:
177
+ index_path = os.path.abspath(index_path)
178
+ if not os.path.exists(index_path):
179
+ raise HTTPException(400, "index not found: %s" % index_path)
180
+
181
+ cpt = torch.load(model_path, map_location="cpu")
182
+ vc.cpt = cpt
183
+ vc.tgt_sr = cpt["config"][-1]
184
+ cpt["config"][-3] = cpt["weight"]["emb_g.weight"].shape[0]
185
+ vc.if_f0 = cpt.get("f0", 1)
186
+ vc.version = cpt.get("version", "v1")
187
+
188
+ cls = {
189
+ ("v1", 1): SynthesizerTrnMs256NSFsid,
190
+ ("v1", 0): SynthesizerTrnMs256NSFsid_nono,
191
+ ("v2", 1): SynthesizerTrnMs768NSFsid,
192
+ ("v2", 0): SynthesizerTrnMs768NSFsid_nono,
193
+ }.get((vc.version, vc.if_f0), SynthesizerTrnMs256NSFsid)
194
+
195
+ vc.net_g = cls(*cpt["config"], is_half=config.is_half)
196
+ del vc.net_g.enc_q
197
+ vc.net_g.load_state_dict(cpt["weight"], strict=False)
198
+ vc.net_g.eval().to(config.device)
199
+ vc.net_g = vc.net_g.half() if config.is_half else vc.net_g.float()
200
+ vc.pipeline = Pipeline(vc.tgt_sr, config)
201
+ vc.hubert_model = None # lazy-loaded by vc_single
202
+ vc.index_path = index_path if index_path else ""
203
+
204
+ _state["model"] = model_path
205
+ _state["index"] = vc.index_path
206
+ print("Loaded model %s (v%s, f0=%s, sr=%s, half=%s)" % (os.path.basename(model_path), vc.version, vc.if_f0, vc.tgt_sr, config.is_half), flush=True)
207
+
208
+
209
+ def decode_to_wav(data):
210
+ """Return a wav file path for the input bytes; decodes mp3/etc via ffmpeg or PyAV.
211
+
212
+ The Windows portable runtime ships ffmpeg.exe; macOS/Linux portable builds may
213
+ not have a bundled ffmpeg, so we fall back to PyAV (already a Python dep in
214
+ the RVC runtime) to decode and downmix to mono WAV.
215
+ """
216
+ if data[:4] == b"RIFF":
217
+ fd, path = tempfile.mkstemp(suffix=".wav")
218
+ with os.fdopen(fd, "wb") as f:
219
+ f.write(data)
220
+ return path
221
+ src = tempfile.mktemp(suffix=".in")
222
+ with open(src, "wb") as f:
223
+ f.write(data)
224
+ dst = tempfile.mktemp(suffix=".wav")
225
+ ff = os.path.join(RVC_DIR, "ffmpeg.exe") if os.path.exists(os.path.join(RVC_DIR, "ffmpeg.exe")) else "ffmpeg"
226
+ ffmpeg_err = b""
227
+ try:
228
+ r = subprocess.run([ff, "-y", "-i", src, "-ac", "1", dst], capture_output=True)
229
+ if r.returncode == 0 and os.path.exists(dst):
230
+ try:
231
+ os.unlink(src)
232
+ except OSError:
233
+ pass
234
+ return dst
235
+ ffmpeg_err = r.stderr or b""
236
+ except FileNotFoundError:
237
+ ffmpeg_err = b"ffmpeg not found; trying PyAV fallback"
238
+ except Exception as e:
239
+ ffmpeg_err = str(e).encode("utf-8", "ignore")
240
+ # PyAV fallback: self-contained for macOS/Linux portable builds and also
241
+ # useful when the system has no ffmpeg in PATH.
242
+ try:
243
+ import av
244
+ from av.audio.resampler import AudioResampler
245
+ with av.open(src) as container:
246
+ stream = container.streams.audio[0]
247
+ rate = stream.rate or 44100
248
+ with av.open(dst, "w", format="wav") as out:
249
+ out_stream = out.add_stream("pcm_s16le", rate=rate)
250
+ out_stream.layout = "mono"
251
+ resampler = AudioResampler(format="s16", layout="mono", rate=rate)
252
+ for frame in container.decode(stream):
253
+ for rf in resampler.resample(frame):
254
+ for packet in out_stream.encode(rf):
255
+ out.mux(packet)
256
+ for rf in resampler.resample(None):
257
+ for packet in out_stream.encode(rf):
258
+ out.mux(packet)
259
+ for packet in out_stream.encode(None):
260
+ out.mux(packet)
261
+ try:
262
+ os.unlink(src)
263
+ except OSError:
264
+ pass
265
+ return dst
266
+ except Exception as e:
267
+ try:
268
+ os.unlink(src)
269
+ except OSError:
270
+ pass
271
+ try:
272
+ os.unlink(dst)
273
+ except OSError:
274
+ pass
275
+ detail = str(e)[-300:]
276
+ if ffmpeg_err:
277
+ detail = "%s | PyAV: %s" % (ffmpeg_err.decode("utf-8", "ignore")[-300:], detail)
278
+ raise HTTPException(400, "audio decode failed: %s" % detail)
279
+
280
+
281
+ def b64(data):
282
+ import base64
283
+ return base64.b64encode(data).decode("ascii")
284
+
285
+
286
+ def unb64(s):
287
+ import base64
288
+ try:
289
+ return base64.b64decode(str(s), validate=False)
290
+ except (binascii.Error, ValueError) as e:
291
+ raise HTTPException(400, "invalid audio_base64: %s" % e)
292
+
293
+
294
+ def trim_edges(audio, sr, lead_ms=250, tail_ms=700, lead_keep_ms=20, tail_keep_ms=120, thr=0.003, fade_ms=5):
295
+ """Trim per-chunk edge silence so chunked playback joins seamlessly.
296
+
297
+ Edge TTS and the RVC pipeline both leave padding silence at clip edges
298
+ (measured: ~140ms lead, ~540ms tail per chunk). Played back-to-back those
299
+ add up to a ~680ms dead gap at every chunk boundary. Trim near-silence
300
+ below `thr` (default -50dB), keep a short natural breath at each edge, and
301
+ apply a tiny fade to avoid clicks.
302
+
303
+ vc_single returns INT16 audio; integer dtypes are normalized to float
304
+ [-1, 1] here (sf.write expects float in [-1,1] and would otherwise clip).
305
+ Returns a float32 array in [-1, 1].
306
+ """
307
+ x = np.asarray(audio)
308
+ if np.issubdtype(x.dtype, np.integer):
309
+ info = np.iinfo(x.dtype)
310
+ x = x.astype(np.float32) / float(info.max)
311
+ else:
312
+ x = x.astype(np.float32)
313
+ n = len(x)
314
+ if n < int(sr * 0.1):
315
+ return x
316
+ absx = np.abs(x)
317
+ lead_n = min(int(sr * lead_ms / 1000), n)
318
+ tail_n = min(int(sr * tail_ms / 1000), n)
319
+ lead = 0
320
+ while lead < lead_n and absx[lead] < thr:
321
+ lead += 1
322
+ tail = 0
323
+ while tail < tail_n and absx[n - 1 - tail] < thr:
324
+ tail += 1
325
+ lead = max(0, lead - int(sr * lead_keep_ms / 1000))
326
+ tail = max(0, tail - int(sr * tail_keep_ms / 1000))
327
+ if lead == 0 and tail == 0:
328
+ return x
329
+ out = np.copy(x[lead: n - tail])
330
+ fade_n = int(sr * fade_ms / 1000)
331
+ if fade_n > 0 and len(out) > 2 * fade_n:
332
+ ramp = np.linspace(0.0, 1.0, fade_n, dtype=np.float32)
333
+ out[:fade_n] *= ramp
334
+ out[-fade_n:] *= ramp[::-1]
335
+ return out
336
+
337
+
338
+ @app.get("/health")
339
+ def health():
340
+ gpu_name = None
341
+ vram_gb = None
342
+ if config.device.startswith("cuda") and torch.cuda.is_available():
343
+ try:
344
+ dev_idx = int(config.device.split(":")[-1])
345
+ gpu_name = torch.cuda.get_device_name(dev_idx)
346
+ vram_gb = round(torch.cuda.get_device_properties(dev_idx).total_memory / (1024 ** 3), 1)
347
+ except Exception:
348
+ pass
349
+ return {
350
+ "ok": True,
351
+ "model_loaded": _state["model"] is not None,
352
+ "model": os.path.basename(_state["model"]) if _state["model"] else None,
353
+ "index": os.path.basename(_state["index"]) if _state["index"] else None,
354
+ "device": config.device,
355
+ "is_half": config.is_half,
356
+ "cuda_available": torch.cuda.is_available(),
357
+ "gpu_name": gpu_name,
358
+ "vram_gb": vram_gb,
359
+ "index_cache": INDEX_CACHE,
360
+ "index_cache_entries": len(_faiss_index_cache) if INDEX_CACHE else 0,
361
+ }
362
+
363
+
364
+ def scan_files(kind):
365
+ """Discover local model/index files so the web UI can pick them by path."""
366
+ exts = {".pth"} if kind == "pth" else {".index"}
367
+ roots = [
368
+ os.environ.get("weight_root", os.path.join(RVC_DIR, "assets", "weights")),
369
+ os.environ.get("index_root", os.path.join(RVC_DIR, "assets", "indices")),
370
+ ]
371
+ logs = os.path.join(RVC_DIR, "logs")
372
+ if os.path.isdir(logs):
373
+ roots.append(logs)
374
+ # the plugin's voice-pack install dir, so downloaded packs show up in the
375
+ # browse picker (installed files are named <packId>.pth / <packId>.index)
376
+ pack_root = os.path.expanduser(os.path.join("~", ".dsh", "tts-rvc", "packs"))
377
+ if os.path.isdir(pack_root):
378
+ roots.append(pack_root)
379
+ out = []
380
+ seen = set()
381
+ for root in roots:
382
+ if not os.path.isdir(root):
383
+ continue
384
+ for dirpath, _, names in os.walk(root):
385
+ for n in names:
386
+ if os.path.splitext(n)[1].lower() in exts:
387
+ p = os.path.abspath(os.path.join(dirpath, n))
388
+ if p in seen:
389
+ continue
390
+ seen.add(p)
391
+ try:
392
+ size = os.path.getsize(p)
393
+ except OSError:
394
+ size = 0
395
+ out.append({"name": n, "path": p, "size": size})
396
+ out.sort(key=lambda f: f["name"].lower())
397
+ return out
398
+
399
+
400
+ @app.get("/files")
401
+ def files(kind: str = "pth"):
402
+ if kind not in ("pth", "index"):
403
+ raise HTTPException(400, "kind must be pth|index")
404
+ return {"ok": True, "kind": kind, "files": scan_files(kind)}
405
+
406
+
407
+ @app.post("/load")
408
+ def load(payload: dict):
409
+ with _lock:
410
+ load_model(payload.get("model"), payload.get("index"))
411
+ _clear_index_cache() # index may have changed; drop cached faiss objects
412
+ return {"ok": True, "model": _state["model"], "index": _state["index"]}
413
+
414
+
415
+ @app.post("/compact-index")
416
+ def compact_index(payload: dict):
417
+ """Build a compact index from a big trained index.
418
+
419
+ RVC indexes (IVF, ~hundreds of MB) are sub-sampled into a small exact flat
420
+ index with the SAME metric as the source (RVC trains with index_factory
421
+ which defaults to L2; the pipeline's square(1/score) weighting is built for
422
+ L2, so a wrong metric inverts the neighbor ranking). Flat search over the
423
+ sample is exact over the sample. Output is written next to the source (or
424
+ into out_dir) and never overwrites it.
425
+ """
426
+ src = str(payload.get("index") or "").strip().strip('"')
427
+ if not src or not os.path.exists(src):
428
+ raise HTTPException(400, "index not found: %s" % src)
429
+ try:
430
+ target = int(payload.get("target_vectors", 10000))
431
+ except (TypeError, ValueError):
432
+ target = 10000
433
+ target = max(500, min(target, 200000))
434
+ out_dir = str(payload.get("out_dir") or "").strip().strip('"')
435
+ if not out_dir:
436
+ out_dir = os.path.dirname(os.path.abspath(src))
437
+ out_dir = os.path.abspath(out_dir)
438
+ with _lock:
439
+ try:
440
+ idx = _faiss.read_index(src)
441
+ ntotal = int(idx.ntotal)
442
+ d = int(idx.d)
443
+ try:
444
+ metric = int(getattr(idx, "metric_type", _faiss.METRIC_L2))
445
+ except Exception:
446
+ metric = _faiss.METRIC_L2
447
+ src_size = os.path.getsize(src)
448
+ if ntotal <= target:
449
+ return {
450
+ "ok": True, "already_small": True,
451
+ "path": os.path.abspath(src),
452
+ "vectors": ntotal, "source_vectors": ntotal,
453
+ "size": src_size, "source_size": src_size,
454
+ "reduction_pct": 0.0,
455
+ }
456
+ big = idx.reconstruct_n(0, ntotal)
457
+ rng = np.random.RandomState(42)
458
+ sample = np.ascontiguousarray(big[rng.permutation(ntotal)[:target]], dtype="float32")
459
+ new_idx = (
460
+ _faiss.IndexFlatIP(d) if metric == _faiss.METRIC_INNER_PRODUCT
461
+ else _faiss.IndexFlatL2(d)
462
+ )
463
+ new_idx.add(sample)
464
+ stem = os.path.splitext(os.path.basename(src))[0]
465
+ out_path = os.path.join(out_dir, "%s_compact_%d.index" % (stem, target))
466
+ _faiss.write_index(new_idx, out_path)
467
+ out_size = os.path.getsize(out_path)
468
+ return {
469
+ "ok": True, "path": out_path, "size": out_size,
470
+ "vectors": target, "source_vectors": ntotal,
471
+ "source_size": src_size,
472
+ "reduction_pct": round((1 - out_size / src_size) * 100, 1),
473
+ }
474
+ except HTTPException:
475
+ raise
476
+ except Exception as e:
477
+ return JSONResponse(
478
+ status_code=500,
479
+ content={"code": "compact_index_error",
480
+ "message": "%s\n%s" % (e, traceback.format_exc()[-600:])},
481
+ )
482
+
483
+
484
+ @app.post("/convert")
485
+ def convert(payload: dict):
486
+ if _state["model"] is None:
487
+ raise HTTPException(400, "no model loaded; call /load first")
488
+ audio_b64 = payload.get("audio_base64")
489
+ if not audio_b64:
490
+ raise HTTPException(400, "audio_base64 required")
491
+ p = payload.get("params") or {}
492
+ spk_id = int(p.get("spk_id", 0))
493
+ f0_file = str(p.get("f0_file") or "").strip().strip('"')
494
+ f0_up_key = int(p.get("f0_up_key", 0))
495
+ f0_method = str(p.get("f0_method", "rmvpe"))
496
+ # validate early so a bad f0_method never reaches infer (which crashes with
497
+ # an UnboundLocalError and a 500). Keep the RVC f0 method set.
498
+ _F0_METHODS = {"rmvpe", "pm", "harvest", "crepe", "rmvpe+", "mangio-crepe", "fcpe"}
499
+ if f0_method not in _F0_METHODS:
500
+ raise HTTPException(400, "unknown f0_method: %r (expected one of %s)" % (f0_method, sorted(_F0_METHODS)))
501
+ index_rate = float(p.get("index_rate", 0.75))
502
+ filter_radius = int(p.get("filter_radius", 3))
503
+ resample_sr = int(p.get("resample_sr", 40000))
504
+ rms_mix_rate = float(p.get("rms_mix_rate", 0.25))
505
+ protect = float(p.get("protect", 0.33))
506
+
507
+ with _lock:
508
+ try:
509
+ data = unb64(audio_b64)
510
+ wav_path = decode_to_wav(data)
511
+ try:
512
+ # Validate the decoded audio is actually decodable + has samples.
513
+ # Without this, an empty/corrupt wav falls through to infer's
514
+ # load_audio which dies with a RuntimeError -> 500. Treat it as
515
+ # a client error (400) instead.
516
+ try:
517
+ with sf.SoundFile(wav_path) as s:
518
+ frames = s.frames
519
+ except Exception:
520
+ frames = 0
521
+ if frames <= 0:
522
+ raise HTTPException(400, "audio decode failed: empty or unreadable audio")
523
+ # pipeline expects f0_file to be an object with a `.name` path
524
+ f0_handle = (
525
+ type("F0File", (), {"name": os.path.abspath(f0_file)})()
526
+ if f0_file
527
+ else None
528
+ )
529
+ # file_index="" + file_index2=<index> bypasses the WebUI's
530
+ # "trained"->"added" auto-rewrite; empty index = index-free mode.
531
+ info, opt = vc.vc_single(
532
+ spk_id, wav_path, f0_up_key, f0_handle, f0_method,
533
+ "", _state["index"], index_rate,
534
+ filter_radius, resample_sr, rms_mix_rate, protect,
535
+ )
536
+ finally:
537
+ try:
538
+ os.unlink(wav_path)
539
+ except OSError:
540
+ pass
541
+ if opt is None or opt[0] is None:
542
+ raise HTTPException(500, "inference failed: %s" % str(info)[-500:])
543
+ tgt_sr, audio = opt
544
+ audio = trim_edges(audio, int(tgt_sr))
545
+ buf = io.BytesIO()
546
+ sf.write(buf, audio, int(tgt_sr), format="wav")
547
+ return {"audio_base64": b64(buf.getvalue()), "sample_rate": int(tgt_sr)}
548
+ except HTTPException:
549
+ raise
550
+ except Exception as e:
551
+ return JSONResponse(
552
+ status_code=500,
553
+ content={"code": "inference_error", "message": "%s\n%s" % (e, traceback.format_exc()[-600:])},
554
+ )
555
+
556
+
557
+ if a.model:
558
+ print("Preloading model...", flush=True)
559
+ load_model(a.model, a.index or "")
560
+
561
+ if __name__ == "__main__":
562
+ import uvicorn
563
+ print("dsh-plugin-tts RVC server: http://%s:%d (device=%s, half=%s)" % (a.host, a.port, config.device, config.is_half), flush=True)
564
+ uvicorn.run(app, host=a.host, port=a.port, log_level="warning")