@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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generates a standalone UI preview page (tools/ui-preview.html) that loads the
|
|
3
|
+
// REAL lib/client.js in a browser, renders the injected components into actual
|
|
4
|
+
// DOM using a lightweight React-agnostic renderer, and lets you screenshot it
|
|
5
|
+
// for visual verification of the plugin UI (P2-P5).
|
|
6
|
+
//
|
|
7
|
+
// node tools/gen-ui-preview.mjs [out.html]
|
|
8
|
+
// Then open out.html in a browser (or headless screenshot):
|
|
9
|
+
// npx playwright screenshot tools/ui-preview.html shot.png (example)
|
|
10
|
+
//
|
|
11
|
+
// Note: this is a visual-verification aid only, NOT shipped into the plugin.
|
|
12
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const clientSrc = readFileSync(path.resolve(here, '..', 'lib', 'client.js'), 'utf8')
|
|
18
|
+
.replace(/<\/script/gi, '<\\/script');
|
|
19
|
+
|
|
20
|
+
const out = path.resolve(here, process.argv[2] || 'ui-preview.html');
|
|
21
|
+
|
|
22
|
+
const html = `<!doctype html>
|
|
23
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>dsh-plugin-tts UI preview</title>
|
|
24
|
+
<style>
|
|
25
|
+
:root{
|
|
26
|
+
/* minimal dsh web theme tokens so --dsw-* styles resolve */
|
|
27
|
+
--dsw-alias-label-primary:#eceff3;
|
|
28
|
+
--dsw-alias-label-secondary:#c3c9d1;
|
|
29
|
+
--dsw-alias-label-tertiary:#7f8894;
|
|
30
|
+
--dsw-alias-label-dimmed:#5a6472;
|
|
31
|
+
--dsw-alias-label-inverse:#0f1115;
|
|
32
|
+
--dsw-alias-label-error:#e5484d;
|
|
33
|
+
--dsw-alias-interactive-bg-hover:rgba(255,255,255,.06);
|
|
34
|
+
--dsw-alias-interactive-bg-primary:#3b82f6;
|
|
35
|
+
--dsw-alias-bg-layer-2:#161a20;
|
|
36
|
+
--dsw-alias-bg-layer-3:#1c2128;
|
|
37
|
+
--dsw-alias-border-secondary:rgba(255,255,255,.08);
|
|
38
|
+
--dsw-alias-border-l2:#2a313b;
|
|
39
|
+
--dsw-alias-brand-primary:#3b82f6;
|
|
40
|
+
}
|
|
41
|
+
body{background:#0d0f13;color:var(--dsw-alias-label-primary);font-family:system-ui,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;margin:0;padding:24px}
|
|
42
|
+
h2{font-size:13px;color:var(--dsw-alias-label-tertiary);font-weight:600;margin:18px 0 8px;text-transform:uppercase;letter-spacing:.5px}
|
|
43
|
+
.card{background:#14181d;border:1px solid var(--dsw-alias-border-secondary);border-radius:12px;padding:14px;margin-bottom:6px;max-width:640px}
|
|
44
|
+
.toolrow{display:flex;align-items:center;gap:6px;padding:6px 8px;background:#15191f;border:1px solid var(--dsw-alias-border-secondary);border-radius:10px;margin-bottom:8px}
|
|
45
|
+
.toolrow::before{content:"▍";color:var(--dsw-alias-label-dimmed);font-size:13px}
|
|
46
|
+
pre.tip{color:var(--dsw-alias-label-tertiary);font-size:11px;margin:2px 0 0}
|
|
47
|
+
</style></head>
|
|
48
|
+
<body>
|
|
49
|
+
<h2>1) auto-read pill (off)</h2>
|
|
50
|
+
<div class="toolrow"><div class="slot-input" data-state="off"></div></div>
|
|
51
|
+
<h2>2) auto-read pill (on)</h2>
|
|
52
|
+
<div class="toolrow"><div class="slot-input" data-state="on"></div></div>
|
|
53
|
+
<h2>3) voice settings panel — Edge provider</h2>
|
|
54
|
+
<div class="card"><div class="slot-settings" data-provider="edge"></div></div>
|
|
55
|
+
<h2>4) voice settings panel — RVC provider (onboarding)</h2>
|
|
56
|
+
<div class="card"><div class="slot-settings" data-provider="rvc"></div></div>
|
|
57
|
+
<h2>5) selection-read chip (demo)</h2>
|
|
58
|
+
<div class="card"><button type="button" class="dsh-tts-sel-btn">朗读选中文本</button> <span style="color:var(--dsw-alias-label-tertiary);font-size:11px">← 悬浮在选择旁的“朗读选中”chip(真实交互中由 mouseup 触发)</span></div>
|
|
59
|
+
|
|
60
|
+
<script>
|
|
61
|
+
/* ---------- minimal React-agnostic shim + hooks + DOM renderer ---------- */
|
|
62
|
+
let activeHooks = null;
|
|
63
|
+
const react = {
|
|
64
|
+
createElement(type, props, ...children){
|
|
65
|
+
const flat=[];
|
|
66
|
+
const push=(x)=>{ if(x==null||x===false||x===true)return; if(Array.isArray(x)){x.forEach(push);return;} flat.push(x); };
|
|
67
|
+
children.forEach(push);
|
|
68
|
+
return {type, props: props||{}, children: flat};
|
|
69
|
+
},
|
|
70
|
+
Fragment: Symbol('frag'),
|
|
71
|
+
useState:i=>activeHooks.useState(i),
|
|
72
|
+
useEffect:(f,d)=>activeHooks.effect(f,d),
|
|
73
|
+
useRef:i=>activeHooks.ref(i),
|
|
74
|
+
useMemo:f=>activeHooks.memo(f),
|
|
75
|
+
};
|
|
76
|
+
function makeHooks(){
|
|
77
|
+
let c=0; const mem=[];
|
|
78
|
+
return {
|
|
79
|
+
useState(init){ const i=c++; if(!(i in mem)) mem[i]=typeof init==='function'?init():init; return [mem[i],()=>{}]; },
|
|
80
|
+
effect(){ c++; /* do not run component effects (no interaction/nav) */ },
|
|
81
|
+
ref(init){ c++; return {current:init}; },
|
|
82
|
+
memo(f){ c++; try{return f();}catch(e){return undefined;} },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function mount(v){
|
|
86
|
+
if(v==null||typeof v==='boolean') return null;
|
|
87
|
+
if(typeof v==='string'||typeof v==='number') return document.createTextNode(String(v));
|
|
88
|
+
if(v.type===react.Fragment){ const f=document.createDocumentFragment(); (v.children||[]).forEach(c=>{const n=mount(c);if(n)f.appendChild(n);}); return f; }
|
|
89
|
+
if(typeof v.type==='function'){ activeHooks=makeHooks(); const n=mount(v.type(v.props) ); activeHooks=null; return n; }
|
|
90
|
+
const el=document.createElement(v.type);
|
|
91
|
+
const p=v.props||{};
|
|
92
|
+
for(const k in p){
|
|
93
|
+
const val=p[k];
|
|
94
|
+
if(k==='children'||k==='key'||k==='ref') continue;
|
|
95
|
+
if(typeof val==='function') continue; // handlers ignored for static render
|
|
96
|
+
if(k==='className'){ el.setAttribute('class', val); }
|
|
97
|
+
else if(k==='style'&&val&&typeof val==='object'){ for(const sk in val){ try{ el.style[sk]=val[sk]; }catch(e){} } }
|
|
98
|
+
else if(k==='value'){ try{ el.value=val; }catch(e){ el.setAttribute('value',val); } }
|
|
99
|
+
else if(k==='disabled'){ if(val) el.setAttribute('disabled',''); }
|
|
100
|
+
else { try{ el.setAttribute(k, val); }catch(e){} }
|
|
101
|
+
}
|
|
102
|
+
(v.children||[]).forEach(c=>{ const n=mount(c); if(n)el.appendChild(n); });
|
|
103
|
+
return el;
|
|
104
|
+
}
|
|
105
|
+
/* ---------- slots / ctx (mirrors client-load) ---------- */
|
|
106
|
+
const injectedComponents=[];
|
|
107
|
+
const slots={
|
|
108
|
+
inject(slot,fn){ injectedComponents.push({slot,fn}); },
|
|
109
|
+
register(spec,component){ return component; },
|
|
110
|
+
};
|
|
111
|
+
const ctx={ get(n){ return n==='slots'?slots:undefined; }, effect(fn){ try{ fn(); }catch(e){} } };
|
|
112
|
+
|
|
113
|
+
/* ---------- load client bundle once; we'll re-eval per state ---------- */
|
|
114
|
+
const CLIENT = ${JSON.stringify(clientSrc)};
|
|
115
|
+
function evalClient(){
|
|
116
|
+
injectedComponents.length=0;
|
|
117
|
+
window.__ModuleLoader__={
|
|
118
|
+
load({factory}){
|
|
119
|
+
const require_=name=>{ if(name==='react') return react; throw new Error('unknown require '+name); };
|
|
120
|
+
const mod=factory(require_);
|
|
121
|
+
if(!mod||!mod.apply) throw new Error('no apply');
|
|
122
|
+
mod.apply(ctx);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const fn=new Function('window','navigator','document','Audio', CLIENT + '\\n;return window.__ModuleLoader__;');
|
|
126
|
+
fn(window,navigator,document,Audio);
|
|
127
|
+
}
|
|
128
|
+
function seedSettings(obj){
|
|
129
|
+
if(obj) localStorage.setItem('dsh-tts-settings', JSON.stringify(obj));
|
|
130
|
+
else localStorage.removeItem('dsh-tts-settings');
|
|
131
|
+
}
|
|
132
|
+
function renderSlot(slotName, container){
|
|
133
|
+
try{
|
|
134
|
+
const c=injectedComponents.find(x=>x.slot===slotName);
|
|
135
|
+
if(!c) throw new Error('no component for '+slotName);
|
|
136
|
+
const comp=c.fn();
|
|
137
|
+
if(typeof comp!=='function') throw new Error(slotName+' fn did not return component');
|
|
138
|
+
activeHooks=makeHooks();
|
|
139
|
+
const node=comp({ session:null, sessionId:'s1', useSession:s=>s({nodes:[]}), messageId:'m1' });
|
|
140
|
+
const dom=mount(node);
|
|
141
|
+
activeHooks=null;
|
|
142
|
+
if(dom) container.appendChild(dom);
|
|
143
|
+
}catch(e){
|
|
144
|
+
logErr(slotName+': '+ (e&&e.stack||e));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function logErr(msg){
|
|
148
|
+
const el=document.createElement('pre');
|
|
149
|
+
el.style.cssText='color:#e5484d;font-size:11px;white-space:pre-wrap';
|
|
150
|
+
el.textContent=msg;
|
|
151
|
+
document.body.appendChild(el);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* off */
|
|
155
|
+
seedSettings({autoRead:false, provider:'edge-tts', voice:'zh-CN-XiaoxuanNeural'});
|
|
156
|
+
evalClient(); renderSlot('conversation.input.left', document.querySelector('.slot-input[data-state=off]'));
|
|
157
|
+
|
|
158
|
+
/* on */
|
|
159
|
+
seedSettings({autoRead:true, provider:'edge-tts', voice:'zh-CN-YunyangNeural'});
|
|
160
|
+
evalClient(); renderSlot('conversation.input.left', document.querySelector('.slot-input[data-state=on]'));
|
|
161
|
+
|
|
162
|
+
/* settings edge */
|
|
163
|
+
seedSettings({autoRead:false, provider:'edge-tts', voice:'zh-CN-XiaoxuanNeural'});
|
|
164
|
+
evalClient(); renderSlot('settings.plugins.tab', document.querySelector('.slot-settings[data-provider=edge]'));
|
|
165
|
+
|
|
166
|
+
/* settings rvc (shows onboarding) */
|
|
167
|
+
seedSettings({autoRead:false, provider:'rvc', voice:'zh-CN-XiaoxuanNeural', rvc:{model:'C:/rvc/model.pth',index:'C:/rvc/model.index',f0UpKey:-2,indexRate:0.75,protect:0.33}});
|
|
168
|
+
evalClient(); renderSlot('settings.plugins.tab', document.querySelector('.slot-settings[data-provider=rvc]'));
|
|
169
|
+
</script>
|
|
170
|
+
</body></html>
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
writeFileSync(out, html, 'utf8');
|
|
174
|
+
console.log('wrote', out);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# mac-verify.sh — end-to-end verification of the dsh-plugin-tts RVC server on macOS.
|
|
3
|
+
#
|
|
4
|
+
# Checks, in order:
|
|
5
|
+
# 1. GET /health server up + device/CPU info
|
|
6
|
+
# 2. GET /files?kind=pth model discovery
|
|
7
|
+
# 3. POST /load load the voice model (+ optional index)
|
|
8
|
+
# 4. POST /convert real conversion of synthesized speech
|
|
9
|
+
# 5. output file sanity (RIFF/duration)
|
|
10
|
+
# 6. POST /compact-index (only when an index is given)
|
|
11
|
+
#
|
|
12
|
+
# Usage: bash mac-verify.sh <model.pth> [index.index] [host:port]
|
|
13
|
+
# PY env var overrides the python used for JSON/base64 (default python3 —
|
|
14
|
+
# use the rvc-work venv python: ~/rvc-work/venv/bin/python)
|
|
15
|
+
#
|
|
16
|
+
# Exit code 0 = all checks passed; otherwise the failing step's exit code.
|
|
17
|
+
set -u
|
|
18
|
+
|
|
19
|
+
MODEL="${1:-}"
|
|
20
|
+
INDEX="${2:-}"
|
|
21
|
+
HOST="${3:-127.0.0.1:4892}"
|
|
22
|
+
BASE="http://$HOST"
|
|
23
|
+
PY="${PY:-python3}"
|
|
24
|
+
FAIL=0
|
|
25
|
+
|
|
26
|
+
if [ -z "$MODEL" ]; then
|
|
27
|
+
echo "usage: bash mac-verify.sh <model.pth> [index.index] [host:port]"
|
|
28
|
+
exit 2
|
|
29
|
+
fi
|
|
30
|
+
if ! command -v "$PY" >/dev/null 2>&1; then
|
|
31
|
+
echo "ERROR: python not found: $PY (set PY=.../venv/bin/python)"
|
|
32
|
+
exit 2
|
|
33
|
+
fi
|
|
34
|
+
echo "== target: $BASE model: $MODEL index: ${INDEX:-<none>} py: $PY =="
|
|
35
|
+
|
|
36
|
+
step() { echo; echo "== [$1/6] $2 =="; }
|
|
37
|
+
|
|
38
|
+
step 1 "/health"
|
|
39
|
+
curl -sS --max-time 15 "$BASE/health" | "$PY" -c '
|
|
40
|
+
import json, sys
|
|
41
|
+
d = json.load(sys.stdin)
|
|
42
|
+
print(json.dumps(d, ensure_ascii=False, indent=1))
|
|
43
|
+
assert d.get("ok") is True, "health ok != true"
|
|
44
|
+
assert d.get("device") == "cpu", "expected cpu device on macOS, got %r" % d.get("device")
|
|
45
|
+
assert d.get("cuda_available") is False, "cuda must not be available on macOS"
|
|
46
|
+
' || FAIL=1
|
|
47
|
+
|
|
48
|
+
step 2 "/files?kind=pth"
|
|
49
|
+
curl -sS --max-time 15 "$BASE/files?kind=pth" | "$PY" -c '
|
|
50
|
+
import json, sys
|
|
51
|
+
d = json.load(sys.stdin)
|
|
52
|
+
files = d.get("files", [])
|
|
53
|
+
print("discovered %d .pth files" % len(files))
|
|
54
|
+
for f in files[:5]:
|
|
55
|
+
print(" ", f["name"], f["size"])
|
|
56
|
+
' || FAIL=1
|
|
57
|
+
|
|
58
|
+
step 3 "/load"
|
|
59
|
+
"$PY" - "$BASE" "$MODEL" "$INDEX" <<'PYEOF' || FAIL=1
|
|
60
|
+
import json, sys, urllib.request
|
|
61
|
+
base, model, index = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
62
|
+
payload = {"model": model}
|
|
63
|
+
if index:
|
|
64
|
+
payload["index"] = index
|
|
65
|
+
req = urllib.request.Request(base + "/load",
|
|
66
|
+
data=json.dumps(payload).encode(),
|
|
67
|
+
headers={"Content-Type": "application/json"})
|
|
68
|
+
try:
|
|
69
|
+
with urllib.request.urlopen(req, timeout=300) as r:
|
|
70
|
+
print(json.dumps(json.load(r), ensure_ascii=False))
|
|
71
|
+
except urllib.error.HTTPError as e:
|
|
72
|
+
print("load HTTP %s: %s" % (e.code, e.read().decode("utf-8", "ignore")[:600]))
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
PYEOF
|
|
75
|
+
|
|
76
|
+
step 4 "/convert (real speech -> converted wav)"
|
|
77
|
+
TMPD="$(mktemp -d)"
|
|
78
|
+
trap 'rm -rf "$TMPD"' EXIT
|
|
79
|
+
if command -v say >/dev/null 2>&1; then
|
|
80
|
+
say -o "$TMPD/speech.aiff" "你好,这是一段跨平台语音转换测试。" 2>/dev/null \
|
|
81
|
+
&& ffmpeg -y -loglevel error -i "$TMPD/speech.aiff" -ac 1 -ar 44100 "$TMPD/speech.wav"
|
|
82
|
+
fi
|
|
83
|
+
if [ ! -s "$TMPD/speech.wav" ]; then
|
|
84
|
+
echo "note: say/ffmpeg failed, synthesizing a 3s tone instead"
|
|
85
|
+
ffmpeg -y -loglevel error -f lavfi -i "sine=frequency=440:duration=3" -ac 1 -ar 44100 "$TMPD/speech.wav"
|
|
86
|
+
fi
|
|
87
|
+
ls -l "$TMPD/speech.wav"
|
|
88
|
+
"$PY" - "$BASE" "$TMPD/speech.wav" "$TMPD/out.wav" <<'PYEOF' || FAIL=1
|
|
89
|
+
import base64, json, sys, time, urllib.request
|
|
90
|
+
base, wav, out = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
91
|
+
data = open(wav, "rb").read()
|
|
92
|
+
payload = {
|
|
93
|
+
"audio_base64": base64.b64encode(data).decode("ascii"),
|
|
94
|
+
"params": {"f0_method": "rmvpe", "index_rate": 0.75},
|
|
95
|
+
}
|
|
96
|
+
t0 = time.time()
|
|
97
|
+
req = urllib.request.Request(base + "/convert",
|
|
98
|
+
data=json.dumps(payload).encode(),
|
|
99
|
+
headers={"Content-Type": "application/json"})
|
|
100
|
+
try:
|
|
101
|
+
with urllib.request.urlopen(req, timeout=900) as r:
|
|
102
|
+
resp = json.load(r)
|
|
103
|
+
except urllib.error.HTTPError as e:
|
|
104
|
+
print("convert HTTP %s: %s" % (e.code, e.read().decode("utf-8", "ignore")[:800]))
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
wall = time.time() - t0
|
|
107
|
+
out_b64 = resp.get("audio_base64")
|
|
108
|
+
if not out_b64:
|
|
109
|
+
print("CONVERT FAILED (no audio in response):", json.dumps(resp, ensure_ascii=False)[:600])
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
open(out, "wb").write(base64.b64decode(out_b64))
|
|
112
|
+
sr = int(resp.get("sample_rate", 0))
|
|
113
|
+
in_sec = len(data) / 2.0 / 44100.0
|
|
114
|
+
print("convert ok: wall=%.1fs sample_rate=%s out=%s (%.2fx realtime)" % (wall, sr, out, wall / max(in_sec, 0.01)))
|
|
115
|
+
PYEOF
|
|
116
|
+
|
|
117
|
+
step 5 "output sanity (RIFF + duration)"
|
|
118
|
+
# RIFF magic check. NB: macOS `od -An -tx1` pads bytes with *two* spaces
|
|
119
|
+
# ("52 49 46 46") while GNU `od` uses one ("52 49 46 46"), so a literal
|
|
120
|
+
# "52 49 46 46" grep wrongly fails on macOS even for a valid RIFF file.
|
|
121
|
+
# Strip whitespace and compare the raw hex instead (portable across both).
|
|
122
|
+
if command -v xxd >/dev/null 2>&1; then
|
|
123
|
+
MAGIC="$(head -c 4 "$TMPD/out.wav" | xxd -p | tr -d '[:space:]')"
|
|
124
|
+
else
|
|
125
|
+
MAGIC="$(head -c 4 "$TMPD/out.wav" | od -An -tx1 | tr -d '[:space:]')"
|
|
126
|
+
fi
|
|
127
|
+
if [ "$MAGIC" != "52494646" ]; then echo "FAIL: not a RIFF file"; FAIL=1; fi
|
|
128
|
+
if command -v ffprobe >/dev/null 2>&1; then
|
|
129
|
+
DUR="$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$TMPD/out.wav" 2>/dev/null)"
|
|
130
|
+
echo "out.wav duration: ${DUR:-unknown}s (input speech was ~3s)"
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
if [ -n "$INDEX" ]; then
|
|
134
|
+
step 6 "/compact-index"
|
|
135
|
+
"$PY" - "$BASE" "$INDEX" <<'PYEOF' || FAIL=1
|
|
136
|
+
import json, sys, urllib.request
|
|
137
|
+
base, idx = sys.argv[1], sys.argv[2]
|
|
138
|
+
req = urllib.request.Request(base + "/compact-index",
|
|
139
|
+
data=json.dumps({"index": idx, "target_vectors": 2000}).encode(),
|
|
140
|
+
headers={"Content-Type": "application/json"})
|
|
141
|
+
try:
|
|
142
|
+
with urllib.request.urlopen(req, timeout=900) as r:
|
|
143
|
+
print(json.dumps(json.load(r), ensure_ascii=False, indent=1))
|
|
144
|
+
except urllib.error.HTTPError as e:
|
|
145
|
+
print("compact-index HTTP %s: %s" % (e.code, e.read().decode("utf-8", "ignore")[:600]))
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
PYEOF
|
|
148
|
+
else
|
|
149
|
+
step 6 "skipped (/compact-index needs an index path)"
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
echo
|
|
153
|
+
if [ "$FAIL" -eq 0 ]; then
|
|
154
|
+
echo "ALL CHECKS PASSED (macOS)"
|
|
155
|
+
else
|
|
156
|
+
echo "SOME CHECKS FAILED — see output above"
|
|
157
|
+
fi
|
|
158
|
+
exit "$FAIL"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// make-pack.mjs — one-command voice-pack addition for a dsh-plugin-tts pack repo.
|
|
3
|
+
//
|
|
4
|
+
// Scans a folder for a model (.pth) + index files (.index), copies them into
|
|
5
|
+
// <repo>/packs/<id>/, computes size + sha256, and merges a manifest.json entry
|
|
6
|
+
// (schema 2, multi-index). No dependencies beyond Node.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// node tools/make-pack.mjs --id azusa2 --name "Azusa 2" \
|
|
10
|
+
// --dir D:/incoming/azusa2 --repo . \
|
|
11
|
+
// [--desc "..."] [--author me] [--license author-owned]
|
|
12
|
+
// [--base-voice zh-CN-YunyangNeural] [--f0-method rmvpe] [--index-rate 0.75]
|
|
13
|
+
// [--version 1.0.0]
|
|
14
|
+
//
|
|
15
|
+
// Validate an existing repo (existence + sha256 + size for every pack):
|
|
16
|
+
// node tools/make-pack.mjs --check --repo .
|
|
17
|
+
import {
|
|
18
|
+
readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync,
|
|
19
|
+
readdirSync, statSync
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
28
|
+
if (!a.startsWith('--')) continue;
|
|
29
|
+
const key = a.slice(2);
|
|
30
|
+
const next = argv[i + 1];
|
|
31
|
+
if (next !== undefined && !next.startsWith('--')) { out[key] = next; i++; }
|
|
32
|
+
else out[key] = true;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const args = parseArgs(process.argv.slice(2));
|
|
38
|
+
const repo = path.resolve(args.repo || '.');
|
|
39
|
+
const manifestPath = path.join(repo, 'manifest.json');
|
|
40
|
+
|
|
41
|
+
function sha256(p) {
|
|
42
|
+
return createHash('sha256').update(readFileSync(p)).digest('hex');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadManifest() {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return { schema: 2, updated: '', packs: [] };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveManifest(m) {
|
|
54
|
+
m.schema = 2;
|
|
55
|
+
m.updated = new Date().toISOString();
|
|
56
|
+
writeFileSync(manifestPath, JSON.stringify(m, null, 2));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- --check: validate every pack in the manifest ---------------------------
|
|
60
|
+
if (args.check) {
|
|
61
|
+
const m = loadManifest();
|
|
62
|
+
let bad = 0;
|
|
63
|
+
const checkFile = (packId, label, f) => {
|
|
64
|
+
if (!f || !f.url) return;
|
|
65
|
+
const fp = path.join(repo, f.url);
|
|
66
|
+
if (!existsSync(fp)) { console.log(`MISSING ${packId} ${label}: ${f.url}`); bad++; return; }
|
|
67
|
+
const s = sha256(fp);
|
|
68
|
+
if (s !== String(f.sha256).toLowerCase()) { console.log(`SHA-BAD ${packId} ${label}: ${f.url}`); bad++; return; }
|
|
69
|
+
if (f.size && statSync(fp).size !== f.size) { console.log(`SIZE-BAD ${packId} ${label}: ${f.url}`); bad++; }
|
|
70
|
+
console.log(`ok ${packId} ${label}: ${f.url}`);
|
|
71
|
+
};
|
|
72
|
+
for (const p of m.packs) {
|
|
73
|
+
checkFile(p.id, 'model', p.model);
|
|
74
|
+
for (const i of (p.indexes || [])) checkFile(p.id, 'index', i);
|
|
75
|
+
checkFile(p.id, 'index', p.index);
|
|
76
|
+
}
|
|
77
|
+
console.log(bad ? `\nFAIL: ${bad} problem(s) in ${m.packs.length} pack(s)` : `\nOK: ${m.packs.length} pack(s) validated`);
|
|
78
|
+
process.exit(bad ? 1 : 0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- add a pack ------------------------------------------------------------
|
|
82
|
+
const id = String(args.id || '').trim();
|
|
83
|
+
if (!id) { console.error('--id is required (e.g. --id azusa2)'); process.exit(1); }
|
|
84
|
+
const srcDir = path.resolve(String(args.dir || ''));
|
|
85
|
+
if (!existsSync(srcDir)) { console.error(`--dir not found: ${srcDir}`); process.exit(1); }
|
|
86
|
+
|
|
87
|
+
const files = readdirSync(srcDir);
|
|
88
|
+
const modelSrc = files.find(f => f.toLowerCase().endsWith('.pth'));
|
|
89
|
+
const indexSrcs = files.filter(f => f.toLowerCase().endsWith('.index')).sort();
|
|
90
|
+
if (!modelSrc) { console.error(`no .pth model found in ${srcDir}`); process.exit(1); }
|
|
91
|
+
if (!indexSrcs.length) console.log('note: no .index files found -> index-free pack');
|
|
92
|
+
|
|
93
|
+
const stem = id.replace(/[^\w.-]/g, '_');
|
|
94
|
+
const packDir = path.join(repo, 'packs', stem);
|
|
95
|
+
mkdirSync(packDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
// model
|
|
98
|
+
const modelName = stem + '.pth';
|
|
99
|
+
const modelDst = path.join(packDir, modelName);
|
|
100
|
+
copyFileSync(path.join(srcDir, modelSrc), modelDst);
|
|
101
|
+
const model = {
|
|
102
|
+
url: `packs/${stem}/${modelName}`,
|
|
103
|
+
size: statSync(modelDst).size,
|
|
104
|
+
sha256: sha256(modelDst)
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// indexes (each .index file becomes a selectable variant)
|
|
108
|
+
const indexes = [];
|
|
109
|
+
for (const src of indexSrcs) {
|
|
110
|
+
const dst = path.join(packDir, src);
|
|
111
|
+
copyFileSync(path.join(srcDir, src), dst);
|
|
112
|
+
const size = statSync(dst).size;
|
|
113
|
+
const base = path.basename(src, path.extname(src)).replace(/[^\w.-]/g, '_').slice(0, 24) || 'index';
|
|
114
|
+
indexes.push({
|
|
115
|
+
id: base,
|
|
116
|
+
name: `${base}(${(size / 1048576).toFixed(0)}MB)`,
|
|
117
|
+
url: `packs/${stem}/${src}`,
|
|
118
|
+
size,
|
|
119
|
+
sha256: sha256(dst)
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const entry = {
|
|
124
|
+
id,
|
|
125
|
+
name: String(args.name || id),
|
|
126
|
+
description: String(args.desc || ''),
|
|
127
|
+
version: String(args.version || '1.0.0'),
|
|
128
|
+
author: String(args.author || ''),
|
|
129
|
+
license: String(args.license || 'unknown'),
|
|
130
|
+
baseVoice: String(args['base-voice'] || 'zh-CN-YunyangNeural'),
|
|
131
|
+
f0Method: String(args['f0-method'] || 'rmvpe'),
|
|
132
|
+
indexRate: Number(args['index-rate'] ?? 0.75),
|
|
133
|
+
model,
|
|
134
|
+
indexes
|
|
135
|
+
};
|
|
136
|
+
if (!indexes.length) delete entry.indexes;
|
|
137
|
+
|
|
138
|
+
const m = loadManifest();
|
|
139
|
+
if (!Array.isArray(m.packs)) m.packs = [];
|
|
140
|
+
const i = m.packs.findIndex(p => p && p.id === id);
|
|
141
|
+
if (i >= 0) m.packs[i] = entry;
|
|
142
|
+
else m.packs.push(entry);
|
|
143
|
+
saveManifest(m);
|
|
144
|
+
|
|
145
|
+
console.log(`\nadded pack "${entry.name}" (${id}):`);
|
|
146
|
+
console.log(` model ${model.url} ${(model.size / 1048576).toFixed(1)}MB`);
|
|
147
|
+
for (const idx of entry.indexes || []) {
|
|
148
|
+
console.log(` index ${idx.url} ${(idx.size / 1048576).toFixed(1)}MB (variant: ${idx.id})`);
|
|
149
|
+
}
|
|
150
|
+
console.log('\nnext steps:');
|
|
151
|
+
console.log(` git -C "${repo}" add packs/${stem} manifest.json`);
|
|
152
|
+
console.log(` git -C "${repo}" commit -m "add voice pack ${id}"`);
|
|
153
|
+
console.log(` git -C "${repo}" push origin main`);
|
|
154
|
+
console.log('\nverify:');
|
|
155
|
+
console.log(` node tools/make-pack.mjs --check --repo "${repo}"`);
|