@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.
- package/.github/ISSUE_TEMPLATE/bug_report.yml +51 -51
- package/.github/ISSUE_TEMPLATE/config.yml +2 -2
- package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -28
- package/.github/pull_request_template.md +7 -7
- package/.github/workflows/contrib-triage.yml +66 -66
- package/.github/workflows/linked-issue.yml +21 -21
- package/.github/workflows/publish.yml +63 -0
- package/.github/workflows/stale.yml +30 -30
- package/.github/workflows/test.yml +33 -33
- package/.github/workflows/welcome.yml +22 -22
- package/ANALYSIS.md +205 -205
- package/PR-REVIEW.md +74 -74
- package/README.md +335 -335
- package/TODO.md +34 -34
- package/docs/HOST-CONTRACT.md +36 -36
- package/docs/RVC-GUIDE.md +390 -390
- package/docs/USER-GUIDE.md +389 -389
- package/docs/acceptance-checklist.md +147 -147
- package/docs/adaptive-chunked-playback.md +194 -194
- package/docs/macos-test-prompt.md +230 -230
- package/docs/windows-gpu-verify-prompt.md +151 -151
- package/lib/client.js +7164 -7164
- package/lib/index.mjs +3374 -3374
- package/package.json +51 -51
- package/rvc-server.py +564 -564
- package/tests/README.md +45 -45
- package/tests/client-load.mjs +406 -406
- package/tests/e2e-compact-index.mjs +86 -86
- package/tests/e2e-index-ab.mjs +138 -138
- package/tests/e2e-real-rvc.mjs +122 -122
- package/tests/i18n-keys.mjs +82 -82
- package/tests/package-runtime-patch.mjs +88 -88
- package/tests/rvc-server-live.mjs +230 -230
- package/tests/smoke.mjs +1284 -1284
- package/tools/gen-ui-preview.mjs +174 -174
- package/tools/mac-verify.sh +158 -158
- 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")
|