@memef1f1y/dsh-plugin-tts 0.6.0
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/workflows/test.yml +33 -0
- package/ANALYSIS.md +205 -0
- package/LICENSE +21 -0
- package/PR-REVIEW.md +75 -0
- package/README.md +297 -0
- package/README.zh.md +263 -0
- package/Snipaste_2026-08-16_13-28-10.png +0 -0
- package/Snipaste_2026-08-16_13-28-23.png +0 -0
- package/Snipaste_2026-08-16_13-28-37.png +0 -0
- package/Snipaste_2026-08-16_13-29-01.png +0 -0
- package/Snipaste_2026-08-16_13-29-11.png +0 -0
- package/TODO.md +23 -0
- package/cordis.patch.yml +4 -0
- package/docs/CLOUD-GUIDE.md +216 -0
- package/docs/COSYVOICE-GUIDE.md +216 -0
- package/docs/EDGE-GUIDE.md +166 -0
- package/docs/HOST-CONTRACT.md +36 -0
- package/docs/INDEX-TTS2-GUIDE.md +208 -0
- package/docs/RVC-GUIDE.md +390 -0
- package/docs/USER-GUIDE.md +334 -0
- package/docs/acceptance-checklist.md +148 -0
- package/docs/adaptive-chunked-playback.md +194 -0
- package/docs/macos-test-prompt.md +230 -0
- package/docs/windows-gpu-verify-prompt.md +151 -0
- package/lib/client.js +7036 -0
- package/lib/index.mjs +3288 -0
- package/logo.png +0 -0
- package/package.json +52 -0
- package/pnpm-workspace.yaml +7 -0
- package/rvc-server.py +564 -0
- package/tests/README.md +45 -0
- package/tests/client-load.mjs +393 -0
- package/tests/e2e-compact-index.mjs +86 -0
- package/tests/e2e-index-ab.mjs +138 -0
- package/tests/e2e-real-rvc.mjs +122 -0
- package/tests/i18n-keys.mjs +82 -0
- package/tests/mock-registry.mjs +50 -0
- package/tests/package-runtime-patch.mjs +88 -0
- package/tests/rvc-server-live.mjs +230 -0
- package/tests/smoke.mjs +1107 -0
- package/tools/gen-ui-preview.mjs +174 -0
- package/tools/mac-verify.sh +158 -0
- package/tools/make-pack.mjs +155 -0
- package/tools/package-runtime.py +214 -0
package/logo.png
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@memef1f1y/dsh-plugin-tts",
|
|
3
|
+
"description": "Edge TTS 语音大集成插件:消息朗读按钮、自动朗读开关、语音设置面板(Edge TTS / RVC / Index-TTS2 / CosyVoice / Google Cloud TTS)",
|
|
4
|
+
"version": "0.6.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"main": "lib/index.mjs",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"default": "./lib/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"./client": "./lib/client.js",
|
|
15
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"dsh": {
|
|
19
|
+
"bundle": {
|
|
20
|
+
"patch": "./cordis.patch.yml"
|
|
21
|
+
},
|
|
22
|
+
"client": {
|
|
23
|
+
"platform": "web",
|
|
24
|
+
"inject": [
|
|
25
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
26
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
32
|
+
"@deepseek-ai/dsh-web": ">=0.1.0-rc.6",
|
|
33
|
+
"react": "^18.2.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
37
|
+
"@deepseek-ai/dsh-base": ">=0.1.0-rc.6"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node tests/smoke.mjs",
|
|
41
|
+
"test:live": "node tests/rvc-server-live.mjs",
|
|
42
|
+
"test:patch": "node tests/package-runtime-patch.mjs",
|
|
43
|
+
"test:i18n": "node tests/i18n-keys.mjs",
|
|
44
|
+
"test:client": "node tests/client-load.mjs",
|
|
45
|
+
"test:all": "node tests/smoke.mjs && node tests/rvc-server-live.mjs && node tests/package-runtime-patch.mjs && node tests/i18n-keys.mjs && node tests/client-load.mjs"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/1624318455/dsh-plugin-tts.git"
|
|
50
|
+
},
|
|
51
|
+
"license": "MIT"
|
|
52
|
+
}
|
package/rvc-server.py
ADDED
|
@@ -0,0 +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")
|
package/tests/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# 测试标准(dsh-plugin-tts)
|
|
2
|
+
|
|
3
|
+
统一测试入口,分三层:**烟测(smoke)→ 场景+边界(live)→(可选)真实链路**。
|
|
4
|
+
所有测试用 `node` 跑,`PASS/FAIL` 逐行输出 + 末尾汇总 + 退出码(0=全过,1=有失败)。
|
|
5
|
+
|
|
6
|
+
## 快速开始
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm test # 烟测:43 项,纯 mock + 真实 Edge TTS,无需本地 RVC 服务
|
|
10
|
+
npm run test:live # 场景 + 边界:需要本地 rvc-server.py(可自启),见下
|
|
11
|
+
npm run test:patch # 边界:package-runtime.py 的 darwin PyAV 补丁逻辑(纯临时目录,无需 RVC)
|
|
12
|
+
npm run test:i18n # i18n 回归:所有 t(key) 都有中英文、无缺失/多余、UI 无残留硬编码中文
|
|
13
|
+
npm run test:client # 客户端加载+渲染:mock 浏览器环境跑 lib/client.js,验证国际化后 UI 不崩
|
|
14
|
+
npm run test:all # 以上全部
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 各层说明
|
|
18
|
+
|
|
19
|
+
| 层 | 文件 | 依赖 | 覆盖 |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| 烟测 | `tests/smoke.mjs` (`npm test`) | 无需本地 RVC;会用真实 Edge TTS 联网合成 | 路由注册、Edge 合成、mock RVC 链路、分块渐进播放、文件代理、compact-index 代理、诊断、音色包注册表(sha256/代理/进度/多索引/卸载)、make-pack 工具 |
|
|
22
|
+
| 场景+边界 | `tests/rvc-server-live.mjs` (`npm run test:live`) | 本地 RVC 服务(见下) | 真实 `health/files/load/convert/compact-index` 场景 + 协议边界(坏 base64、空音频、未知 f0_method、缺失模型/索引、并发转换、未知路由) |
|
|
23
|
+
| 打包边界 | `tests/package-runtime-patch.mjs` (`npm run test:patch`) | 仅 python3(临时目录) | `package-runtime.py` 的 darwin PyAV 补丁:`av.open` `rb/wb→r/w`、不动 Python `open(file,"rb")`、幂等 |
|
|
24
|
+
| i18n 回归 | `tests/i18n-keys.mjs` (`npm run test:i18n`) | 无 | 中英文词典键集一致、所有 `t(key)` 有双语定义、UI 区无残留硬编码中文、无死键 |
|
|
25
|
+
| 客户端加载/渲染 | `tests/client-load.mjs` (`npm run test:client`) | 无(mock 浏览器) | `lib/client.js` 在 mock 环境加载、`apply()` 运行、4 个组件(auto-read/read/xml/设置面板)渲染不抛错 |
|
|
26
|
+
|
|
27
|
+
## test:live 的环境约定(跨平台友好)
|
|
28
|
+
|
|
29
|
+
`tests/rvc-server-live.mjs` 是**环境感知**的:
|
|
30
|
+
|
|
31
|
+
- 优先连已运行的服务:`RVC_URL`(默认 `http://127.0.0.1:4892`);
|
|
32
|
+
- 没有服务时,尝试从 `RVC_WORK`(默认 `~/rvc-work`,macOS 实测目录)用
|
|
33
|
+
`venv/bin/python rvc-server.py` 自启;
|
|
34
|
+
- 两者都不可用时打印 `SKIP` 并以 0 退出(CI 安全,不会误报失败)。
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
RVC_URL=http://127.0.0.1:4892 npm run test:live # 连已启动的服务
|
|
38
|
+
RVC_WORK=/path/to/rvc-work npm run test:live # 指定工作目录
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 历史遗留
|
|
42
|
+
|
|
43
|
+
`tests/e2e-*.mjs`(`e2e-real-rvc` / `e2e-index-ab` / `e2e-compact-index`)是
|
|
44
|
+
**为 Windows 一次性写的真实链路脚本**(硬编码 `E:/AI/.../azusa-test.pth`),不可移植,
|
|
45
|
+
已被 `tests/rvc-server-live.mjs` 取代并标记 deprecated,仅作 Windows 参考保留。
|