@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.
Files changed (44) hide show
  1. package/.github/workflows/test.yml +33 -0
  2. package/ANALYSIS.md +205 -0
  3. package/LICENSE +21 -0
  4. package/PR-REVIEW.md +75 -0
  5. package/README.md +297 -0
  6. package/README.zh.md +263 -0
  7. package/Snipaste_2026-08-16_13-28-10.png +0 -0
  8. package/Snipaste_2026-08-16_13-28-23.png +0 -0
  9. package/Snipaste_2026-08-16_13-28-37.png +0 -0
  10. package/Snipaste_2026-08-16_13-29-01.png +0 -0
  11. package/Snipaste_2026-08-16_13-29-11.png +0 -0
  12. package/TODO.md +23 -0
  13. package/cordis.patch.yml +4 -0
  14. package/docs/CLOUD-GUIDE.md +216 -0
  15. package/docs/COSYVOICE-GUIDE.md +216 -0
  16. package/docs/EDGE-GUIDE.md +166 -0
  17. package/docs/HOST-CONTRACT.md +36 -0
  18. package/docs/INDEX-TTS2-GUIDE.md +208 -0
  19. package/docs/RVC-GUIDE.md +390 -0
  20. package/docs/USER-GUIDE.md +334 -0
  21. package/docs/acceptance-checklist.md +148 -0
  22. package/docs/adaptive-chunked-playback.md +194 -0
  23. package/docs/macos-test-prompt.md +230 -0
  24. package/docs/windows-gpu-verify-prompt.md +151 -0
  25. package/lib/client.js +7036 -0
  26. package/lib/index.mjs +3288 -0
  27. package/logo.png +0 -0
  28. package/package.json +52 -0
  29. package/pnpm-workspace.yaml +7 -0
  30. package/rvc-server.py +564 -0
  31. package/tests/README.md +45 -0
  32. package/tests/client-load.mjs +393 -0
  33. package/tests/e2e-compact-index.mjs +86 -0
  34. package/tests/e2e-index-ab.mjs +138 -0
  35. package/tests/e2e-real-rvc.mjs +122 -0
  36. package/tests/i18n-keys.mjs +82 -0
  37. package/tests/mock-registry.mjs +50 -0
  38. package/tests/package-runtime-patch.mjs +88 -0
  39. package/tests/rvc-server-live.mjs +230 -0
  40. package/tests/smoke.mjs +1107 -0
  41. package/tools/gen-ui-preview.mjs +174 -0
  42. package/tools/mac-verify.sh +158 -0
  43. package/tools/make-pack.mjs +155 -0
  44. 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}"`);