@oadank/dsh-input-tools 0.3.0 → 0.3.2

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/lib/client.js CHANGED
@@ -425,6 +425,37 @@ window.__ModuleLoader__.load({
425
425
  const [asrInstalling, setAsrInstalling] = useState(false); // 一键安装进行中
426
426
  const [asrCmd, setAsrCmd] = useState(null); // 待手动复制的安装命令
427
427
  const [vdSamples, setVdSamples] = useState([]); // VoiceDesign 官方示例音频(预生成)
428
+ // [2026-08-21] 试听失败的错误提示(之前失败静默无反馈)
429
+ const [previewErr, setPreviewErr] = useState(null);
430
+ // [2026-08-21] API Key 明文/密文切换(眼睛图标)
431
+ const [showKeys, setShowKeys] = useState({});
432
+ // [2026-08-21] 本地 TTS 一键安装命令
433
+ const [ttsInstalling, setTtsInstalling] = useState(false);
434
+ const [ttsCmd, setTtsCmd] = useState(null);
435
+ // [2026-08-21] 密钥输入框 + 眼睛切换(明文/密文),keyName 作 state map 键
436
+ const secretField = (labelText, keyName, value, onChange, placeholder) => h("label", {
437
+ style: { display: "flex", flexDirection: "column", gap: "4px", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: "1 1 45%", minWidth: "220px" },
438
+ }, labelText,
439
+ h("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
440
+ h("input", {
441
+ type: showKeys[keyName] ? "text" : "password",
442
+ value: value,
443
+ onChange: onChange,
444
+ placeholder: placeholder,
445
+ style: { ...vInput, flex: 1 },
446
+ }),
447
+ h("button", {
448
+ type: "button", "aria-label": showKeys[keyName] ? "隐藏密钥" : "显示密钥", title: showKeys[keyName] ? "隐藏密钥" : "显示密钥",
449
+ style: {
450
+ border: "none", borderRadius: "6px", width: "32px", height: "32px", flex: "none",
451
+ background: "rgba(128,128,128,.12)", color: "inherit", cursor: "pointer", fontSize: "14px",
452
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
453
+ },
454
+ onMouseDown: (e) => e.preventDefault(),
455
+ onClick: () => setShowKeys((s) => ({ ...s, [keyName]: !s[keyName] })),
456
+ }, showKeys[keyName] ? "🙈" : "👁"),
457
+ ),
458
+ );
428
459
 
429
460
  useEffect(() => {
430
461
  let dead = false;
@@ -516,11 +547,13 @@ window.__ModuleLoader__.load({
516
547
  };
517
548
 
518
549
  // 音色试听:POST /voice-config/preview → 播放返回音频;tag 用于区分多个试听按钮状态;text/cmd/url 可临时指定
550
+ // [2026-08-21] 失败时显示错误(之前静默无提示,用户填错 API Key 毫无反馈)
519
551
  const previewVoice = (engine, voice, context, samplePath, tag, extra) => {
520
552
  if (previewRef.current !== null) { previewRef.current.pause(); previewRef.current = null; }
521
553
  const curTag = tag ?? engine;
522
554
  previewTagRef.current = curTag;
523
555
  setPreviewing(curTag);
556
+ setPreviewErr(null);
524
557
  fetch("/voice-config/preview", {
525
558
  method: "POST", headers: { "content-type": "application/json" },
526
559
  body: JSON.stringify({
@@ -530,7 +563,7 @@ window.__ModuleLoader__.load({
530
563
  })
531
564
  .then((r) => r.json())
532
565
  .then((d) => {
533
- if (!d?.ok) { if (previewTagRef.current === curTag) setPreviewing(null); return; }
566
+ if (!d?.ok) { if (previewTagRef.current === curTag) setPreviewing(null); setPreviewErr(d?.error ?? "试听失败"); return; }
534
567
  if (previewTagRef.current !== curTag) return; // 已被「再点=停止」或切换,丢弃
535
568
  const audio = new Audio("data:" + d.mediaType + ";base64," + d.data);
536
569
  previewRef.current = audio;
@@ -538,7 +571,7 @@ window.__ModuleLoader__.load({
538
571
  audio.onerror = () => { if (previewTagRef.current === curTag) setPreviewing(null); };
539
572
  audio.play().catch(() => { if (previewTagRef.current === curTag) setPreviewing(null); });
540
573
  })
541
- .catch(() => { if (previewTagRef.current === curTag) setPreviewing(null); });
574
+ .catch((e) => { if (previewTagRef.current === curTag) setPreviewing(null); setPreviewErr(String(e?.message ?? e)); });
542
575
  };
543
576
 
544
577
  // [本地改造 2026-08-21] 试听克隆样本的原始音频(用于和克隆合成效果对比还原度)
@@ -647,6 +680,17 @@ window.__ModuleLoader__.load({
647
680
  });
648
681
  }).catch((e) => { setAsrInstalling(false); setAsrResult({ ok: false, text: String(e) }); });
649
682
  };
683
+ // [2026-08-21] 本地 TTS 一键安装:获取安装命令并显示(与 ASR 同款交互)
684
+ const installLocalTts = () => {
685
+ setTtsInstalling(true);
686
+ setTtsCmd(null);
687
+ fetch("/tts/install-script").then((r) => r.json()).then((d) => {
688
+ setTtsInstalling(false);
689
+ if (!d?.ok) { setTtsCmd(null); setPreviewErr(d?.error ?? "获取安装命令失败"); return; }
690
+ setTtsCmd(d.command);
691
+ setPreviewErr(null);
692
+ }).catch((e) => { setTtsInstalling(false); setPreviewErr(String(e)); });
693
+ };
650
694
 
651
695
  // 提示小问号(hover 浮层显示 / 点击固定);align="right" 时浮层右对齐(向左展开,适合靠左按钮),默认左对齐(向右展开,适合靠右按钮)
652
696
  const helpTip = (text, pinned, setPinned, hover, setHover, align, place) => h("span", { style: { position: "relative", display: "inline-flex", alignItems: "center" } },
@@ -690,6 +734,9 @@ window.__ModuleLoader__.load({
690
734
  // 分区标题(语音图标已移到各服务商卡片前)
691
735
  h("div", { style: { display: "flex", alignItems: "center", gap: "8px", fontSize: "15px", fontWeight: 700, color: "var(--dsw-alias-label-primary,#e6e9ef)" } },
692
736
  "语音服务"),
737
+ // [2026-08-21] 试听失败错误横幅(原来静默无提示)
738
+ previewErr !== null ? h("div", { style: { fontSize: "12.5px", lineHeight: "1.6", color: "#e5484d", border: "1px solid rgba(229,72,77,.4)", borderRadius: "8px", padding: "8px 10px", background: "rgba(229,72,77,.08)", whiteSpace: "pre-wrap" } },
739
+ "试听失败:" + previewErr) : null,
693
740
  // ⑤ ASR 语音识别(必填项,无开关)
694
741
  h("div", { style: { border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "10px", padding: "10px 12px", display: "flex", flexDirection: "column", gap: "8px", background: "rgba(128,128,128,.05)" } },
695
742
  h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
@@ -710,7 +757,7 @@ window.__ModuleLoader__.load({
710
757
  (eng.asr.mode ?? "service") === "service" ? vField("本地服务地址", h("input", { value: eng.asr.url ?? "", onChange: (e) => setEngine("asr", { url: e.target.value }, true), placeholder: "http://127.0.0.1:18790", style: vInput })) : null,
711
758
  (eng.asr.mode ?? "service") === "cmd" ? vField("本地命令", h("input", { value: eng.asr.cmd ?? "", onChange: (e) => setEngine("asr", { cmd: e.target.value }, true), placeholder: "sherpa-onnx-offline.exe --tokens=... --sense-voice-model=... --num-threads=4", style: vInput })) : null,
712
759
  (eng.asr.mode ?? "service") === "api" ? [
713
- vField("API Key", h("input", { type: "password", value: eng.asr.apiKey ?? "", onChange: (e) => setEngine("asr", { apiKey: e.target.value }, true), placeholder: "sk-...", style: vInput })),
760
+ secretField("API Key", "asr", eng.asr.apiKey ?? "", (e) => setEngine("asr", { apiKey: e.target.value }, true), "sk-..."),
714
761
  vField("API 地址", h("input", { value: eng.asr.apiBaseUrl ?? "", onChange: (e) => setEngine("asr", { apiBaseUrl: e.target.value }, true), placeholder: "https://api.openai.com/v1", style: vInput })),
715
762
  ] : null,
716
763
  ),
@@ -847,13 +894,8 @@ window.__ModuleLoader__.load({
847
894
  () => h("div", { style: { display: "flex", flexDirection: "column", gap: "10px" } },
848
895
  // [本地改造 2026-08-21] API Key(卡片最上;不再有模型勾选)
849
896
  h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
850
- vField("API Key", h("input", {
851
- type: "password",
852
- value: eng.xiaomi.apiKey,
853
- onChange: (e) => setEngine("xiaomi", { apiKey: e.target.value }, true),
854
- placeholder: (eng.xiaomi.apiKey !== "" || meta?.envKeys?.xiaomi) ? "已填写——输入新值可替换" : "MIMO_API_KEY",
855
- style: vInput,
856
- })),
897
+ secretField("API Key", "xiaomi", eng.xiaomi.apiKey, (e) => setEngine("xiaomi", { apiKey: e.target.value }, true),
898
+ (eng.xiaomi.apiKey !== "" || meta?.envKeys?.xiaomi) ? "已填写——输入新值可替换" : "MIMO_API_KEY"),
857
899
  ),
858
900
  // 语音模型:MiMo-V2.5-TTS(基础 TTS,音色 + 语言风格)
859
901
  h("div", { style: { display: "flex", flexDirection: "column", gap: "8px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
@@ -945,28 +987,58 @@ window.__ModuleLoader__.load({
945
987
  // ③ 本地 TTS(与其他卡片一致:勾选后才显示配置字段)
946
988
  vCard(h("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", flexWrap: "wrap" } },
947
989
  ENGINE_LABELS.local,
948
- helpTip("本地模型常驻内存(CPU 推理)。填本地命令(每次调用启动进程,较慢);或填 HTTP 服务地址(推荐,模型常驻一次加载后快)。两者都填时 HTTP 优先;留空则跳过本地引擎。", localTipPinned, setLocalTipPinned, localTipHover, setLocalTipHover, "center"),
990
+ helpTip("本地模型常驻内存(CPU 推理)。填本地命令(每次调用启动进程,较慢);或填 HTTP 服务地址(推荐,模型常驻一次加载后快)。两者都填时 HTTP 优先;留空则跳过本地引擎。点「复制安装命令」可一键下载 sherpa-onnx + 中文 MeloTTS 模型 + ffmpeg,并自动生成可用的启动脚本。", localTipPinned, setLocalTipPinned, localTipHover, setLocalTipHover, "center"),
949
991
  ), openCards.local, () => toggleCard("local"),
950
992
  [
951
993
  h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
952
- vField("本地命令(每次调用启动进程)", h("input", { value: eng.local.cmd ?? "", onChange: (e) => setEngine("local", { cmd: e.target.value }, true), placeholder: "如 melo-tts.exe --text {text} --out {out}", style: vInput })),
994
+ vField("本地命令(每次调用启动进程)", h("input", { value: eng.local.cmd ?? "", onChange: (e) => setEngine("local", { cmd: e.target.value }, true), placeholder: "如 node <插件目录>\\local-tts.mjs(安装脚本会自动填好)", style: vInput })),
953
995
  vField("HTTP 服务地址(常驻模式)", h("input", { value: eng.local.url ?? "", onChange: (e) => setEngine("local", { url: e.target.value }, true), placeholder: "如 http://127.0.0.1:5000/tts(POST {text} 返回音频)", style: vInput })),
954
996
  ),
955
- h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
997
+ h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" } },
956
998
  previewBtn("local-preview", "试听本地 TTS", () => previewVoice("local", undefined, undefined, undefined, "local-preview", { cmd: eng.local.cmd ?? "", url: eng.local.url ?? "" })),
957
999
  h("span", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "点击试听(用上方填的命令/地址合成)"),
958
1000
  ),
1001
+ // [2026-08-21] 本地 TTS 一键安装(与 ASR 同款交互)
1002
+ h("div", { style: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
1003
+ h("button", {
1004
+ type: "button",
1005
+ style: {
1006
+ border: "none", borderRadius: "999px", padding: "6px 16px", fontSize: "12.5px", fontWeight: 600,
1007
+ background: ttsInstalling ? "rgba(128,128,128,.15)" : "var(--vk-accent,#4b6fff)",
1008
+ color: "#fff", cursor: "pointer",
1009
+ },
1010
+ onMouseDown: (e) => e.preventDefault(),
1011
+ onClick: installLocalTts,
1012
+ }, ttsInstalling ? "准备命令…" : "复制安装命令"),
1013
+ h("span", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } },
1014
+ "复制命令后,打开「以管理员身份运行」的 PowerShell 粘贴执行。脚本自动下载 sherpa-onnx(含离线 TTS)+ 中文 MeloTTS 模型 + ffmpeg,并生成 local-tts.mjs 启动脚本"),
1015
+ ),
1016
+ ttsCmd !== null ? h("div", { style: { display: "flex", flexDirection: "column", gap: "4px" } },
1017
+ h("div", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "安装命令(点击选中全部,Ctrl+C 复制):"),
1018
+ h("code", {
1019
+ style: {
1020
+ display: "block", fontSize: "12px", lineHeight: "1.6", fontFamily: "Consolas, monospace",
1021
+ color: "var(--dsw-alias-label-primary,#e6e9ef)",
1022
+ border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
1023
+ padding: "8px 10px", background: "rgba(128,128,128,.08)",
1024
+ wordBreak: "break-all", whiteSpace: "pre-wrap", cursor: "text", userSelect: "all",
1025
+ },
1026
+ onMouseDown: (e) => e.preventDefault(),
1027
+ onClick: (e) => {
1028
+ const sel = window.getSelection();
1029
+ const range = document.createRange();
1030
+ range.selectNodeContents(e.currentTarget);
1031
+ sel.removeAllRanges();
1032
+ sel.addRange(range);
1033
+ },
1034
+ }, ttsCmd),
1035
+ ) : null,
959
1036
  ]),
960
1037
  // ④ 阿里 qwen3-tts
961
1038
  vCard(ENGINE_LABELS.ali, openCards.ali, () => toggleCard("ali"),
962
1039
  h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
963
- vField("API Key", h("input", {
964
- type: "password",
965
- value: eng.ali.apiKey ?? "",
966
- onChange: (e) => setEngine("ali", { apiKey: e.target.value }, true),
967
- placeholder: (eng.ali.apiKey !== "" || meta?.envKeys?.ali) ? "已填写——输入新值可替换" : "dashscope API Key",
968
- style: vInput,
969
- })),
1040
+ secretField("API Key", "ali", eng.ali.apiKey ?? "", (e) => setEngine("ali", { apiKey: e.target.value }, true),
1041
+ (eng.ali.apiKey !== "" || meta?.envKeys?.ali) ? "已填写——输入新值可替换" : "dashscope API Key"),
970
1042
  vField("音色", voiceSelect("ali", eng.ali.voice ?? "Cherry", meta?.aliVoices, (v) => setEngine("ali", { voice: v }, true))),
971
1043
  )),
972
1044
  );
package/lib/index.js CHANGED
@@ -1010,6 +1010,34 @@ async function apply(ctx) {
1010
1010
  }
1011
1011
  },
1012
1012
  }))
1013
+ // [2026-08-21] 本地 TTS 配置与安装脚本路由(独立 prefix,勿放进 /asr)
1014
+ disposers.push(ctx.webServer.register({
1015
+ kind: 'prefix',
1016
+ path: '/tts',
1017
+ handler: async (req, res) => {
1018
+ const url = new URL(req.url ?? '/', 'http://x')
1019
+ try {
1020
+ if (url.pathname === '/tts/install-script' && req.method === 'GET') {
1021
+ const here = join(fileURLToPath(import.meta.url), '..') // .../lib
1022
+ const scriptPath = join(here, '..', 'scripts', 'install-local-tts.ps1') // .../scripts
1023
+ try {
1024
+ await readFile(scriptPath, 'utf8') // 确认脚本存在
1025
+ return sendJson(res, 200, {
1026
+ ok: true,
1027
+ scriptPath,
1028
+ installDir: join(here, '..', 'sherpa-onnx'),
1029
+ command: `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`,
1030
+ })
1031
+ } catch {
1032
+ return sendJson(res, 404, { ok: false, error: '安装脚本不存在' })
1033
+ }
1034
+ }
1035
+ return sendJson(res, 404, { ok: false, error: 'not found' })
1036
+ } catch (error) {
1037
+ return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
1038
+ }
1039
+ },
1040
+ }))
1013
1041
  }
1014
1042
 
1015
1043
  // 1) turn/end 自动语音回复(规则同 api-proxy 原实现)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oadank/dsh-input-tools",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "lib",
15
+ "scripts",
15
16
  "README.md",
16
17
  "cordis.patch.yml"
17
18
  ],
@@ -0,0 +1,202 @@
1
+ # ============================================================
2
+ # dsh-host-voice ASR 一键安装脚本(Windows)
3
+ # 安装内容:
4
+ # 1. sherpa-onnx(含 sherpa-onnx-offline.exe 非流式识别)
5
+ # 2. SenseVoice 中英日韩粤模型(int8)
6
+ # 3. ffmpeg(无则用 winget 安装)
7
+ # 4. 注册 nssm 服务 asr(端口 18790,开机自启)
8
+ #
9
+ # 安装位置:自动放在本插件包目录下的 sherpa-onnx/(脚本位于
10
+ # <插件包>/scripts/install-asr.ps1,自动推导到 <插件包>/sherpa-onnx)
11
+ # ——所有人装插件后路径都统一,不会乱。
12
+ # 用法:以管理员身份打开 PowerShell,执行:
13
+ # powershell -ExecutionPolicy Bypass -File "<插件包>\scripts\install-asr.ps1"
14
+ # 可选参数:-Port 18790(自定义端口)
15
+ # ============================================================
16
+ param(
17
+ [int]$Port = 18790
18
+ )
19
+
20
+ $ErrorActionPreference = "Stop"
21
+ $Version = "v1.13.6"
22
+
23
+ # ---- 0. 自动推导安装目录:脚本在 <插件包>/scripts/,安装到 <插件包>/sherpa-onnx/ ----
24
+ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
25
+ $PluginRoot = Split-Path -Parent $ScriptDir
26
+ $InstallDir = Join-Path $PluginRoot "sherpa-onnx"
27
+
28
+ Write-Host "==== dsh ASR 一键安装 ====" -ForegroundColor Cyan
29
+ Write-Host "插件包目录: $PluginRoot"
30
+ Write-Host "安装目录: $InstallDir"
31
+ Write-Host "服务端口: $Port"
32
+
33
+ # ---- 0b. 幂等保护:已完整安装则直接退出(不下载、不注册、不碰任何现有配置)----
34
+ $exeExists = Test-Path "$InstallDir\bin\sherpa-onnx-offline.exe"
35
+ $modelExists = Test-Path "$InstallDir\models\sensevoice-int8\model.int8.onnx"
36
+ $serviceHealthy = $false
37
+ try {
38
+ $r = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 3 -UseBasicParsing -ErrorAction Stop
39
+ $serviceHealthy = ($r.StatusCode -eq 200)
40
+ } catch { }
41
+
42
+ if ($exeExists -and $modelExists -and $serviceHealthy) {
43
+ Write-Host "`n检测到本机已完整安装 sherpa-onnx + SenseVoice 模型,且端口 $Port 服务健康。" -ForegroundColor Green
44
+ Write-Host "无需重复安装,脚本已跳过所有操作(不会改动现有文件和服务)。" -ForegroundColor Green
45
+ exit 0
46
+ }
47
+ if ($exeExists -and $modelExists) {
48
+ Write-Host "`n检测到 sherpa-onnx 与模型已存在,但端口 $Port 服务未运行。" -ForegroundColor Yellow
49
+ Write-Host "将尝试为你注册并启动 nssm 服务(asr)。" -ForegroundColor Yellow
50
+ }
51
+ if ($serviceHealthy) {
52
+ Write-Host "`n检测到端口 $Port 服务健康,但文件不完整——将补齐缺失文件。" -ForegroundColor Yellow
53
+ }
54
+
55
+ # ---- 1. 创建目录 ----
56
+ New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
57
+ New-Item -ItemType Directory -Force -Path "$InstallDir\models" | Out-Null
58
+ New-Item -ItemType Directory -Force -Path "$InstallDir\bin" | Out-Null
59
+ New-Item -ItemType Directory -Force -Path "$InstallDir\tmp" | Out-Null
60
+
61
+ # ---- 2. 检查 ffmpeg(ASR 转码必需)----
62
+ Write-Host "`n[1/4] 检查 ffmpeg..." -ForegroundColor Yellow
63
+ $ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
64
+ if (-not $ffmpeg) {
65
+ Write-Host " 未找到 ffmpeg,尝试 winget 安装(首次安装需同意条款)..." -ForegroundColor Yellow
66
+ try {
67
+ winget install --id Gyan.FFmpeg -e --accept-package-agreements --accept-source-agreements
68
+ $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
69
+ $ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
70
+ } catch {
71
+ Write-Host " winget 安装失败,请手动安装 ffmpeg 并加入 PATH" -ForegroundColor Red
72
+ exit 1
73
+ }
74
+ }
75
+ Write-Host " ffmpeg: $($ffmpeg.Source)" -ForegroundColor Green
76
+
77
+ # ---- 3. 下载 sherpa-onnx ----
78
+ Write-Host "`n[2/4] 下载 sherpa-onnx $Version ..." -ForegroundColor Yellow
79
+ $pkgUrl = "https://github.com/k2-fsa/sherpa-onnx/releases/download/$Version/sherpa-onnx-$Version-win-x64-shared-MD-Release.tar.bz2"
80
+ $pkgFile = "$InstallDir\tmp\sherpa-onnx.tar.bz2"
81
+ if (-not (Test-Path "$InstallDir\bin\sherpa-onnx-offline.exe")) {
82
+ if (-not (Test-Path $pkgFile)) {
83
+ Write-Host " 下载 $pkgUrl"
84
+ Invoke-WebRequest -Uri $pkgUrl -OutFile $pkgFile -UseBasicParsing
85
+ }
86
+ Write-Host " 解压..."
87
+ tar -xjf $pkgFile -C "$InstallDir\tmp"
88
+ # 解压后目录结构:sherpa-onnx-v1.13.6-win-x64-shared-MD-Release/ 内含 bin/ lib/
89
+ $extracted = Get-ChildItem "$InstallDir\tmp" -Directory | Where-Object { $_.Name -like "sherpa-onnx-*win*" } | Select-Object -First 1
90
+ if ($extracted) {
91
+ Copy-Item "$($extracted.FullName)\bin\*" "$InstallDir\bin\" -Force
92
+ Copy-Item "$($extracted.FullName)\lib\*" "$InstallDir\lib\" -Force -ErrorAction SilentlyContinue
93
+ Write-Host " sherpa-onnx 解压完成" -ForegroundColor Green
94
+ } else {
95
+ Write-Host " 解压目录未找到,检查 tmp 目录" -ForegroundColor Red
96
+ exit 1
97
+ }
98
+ } else {
99
+ Write-Host " sherpa-onnx 已存在,跳过下载" -ForegroundColor Green
100
+ }
101
+
102
+ # ---- 4. 下载 SenseVoice 模型 ----
103
+ Write-Host "`n[3/4] 下载 SenseVoice int8 模型..." -ForegroundColor Yellow
104
+ $modelDir = "$InstallDir\models\sensevoice-int8"
105
+ $modelUrl = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2"
106
+ $modelFile = "$InstallDir\tmp\sensevoice.tar.bz2"
107
+ if (-not (Test-Path "$modelDir\model.int8.onnx")) {
108
+ if (-not (Test-Path $modelFile)) {
109
+ Write-Host " 下载 $modelUrl"
110
+ Invoke-WebRequest -Uri $modelUrl -OutFile $modelFile -UseBasicParsing
111
+ }
112
+ New-Item -ItemType Directory -Force -Path $modelDir | Out-Null
113
+ Write-Host " 解压模型..."
114
+ tar -xjf $modelFile -C $modelDir --strip-components=1
115
+ Write-Host " 模型解压完成" -ForegroundColor Green
116
+ } else {
117
+ Write-Host " 模型已存在,跳过下载" -ForegroundColor Green
118
+ }
119
+
120
+ # ---- 5. 写 ASR 服务脚本 ----
121
+ Write-Host "`n[4/4] 写入 ASR 服务并注册 nssm..." -ForegroundColor Yellow
122
+ # 路径统一用正斜杠,避免 JS 字符串把 \s \b 当转义符
123
+ $exePath = "$InstallDir\bin\sherpa-onnx-offline.exe".Replace('\', '/')
124
+ $modelPath = "$InstallDir\models\sensevoice-int8".Replace('\', '/')
125
+ $serviceScript = @"
126
+ const http = require('node:http');
127
+ const { spawnSync } = require('node:child_process');
128
+ const { existsSync } = require('node:fs');
129
+ const PORT = Number(process.env.ASR_SERVICE_PORT || $Port);
130
+ const SHERPA_BIN = process.env.ASR_SHARPA_BIN || '$exePath';
131
+ const MODEL_DIR = process.env.ASR_MODEL_DIR || '$modelPath';
132
+ const server = http.createServer(async (req, res) => {
133
+ if (req.method === 'POST' && req.url === '/transcribe') {
134
+ let body = '';
135
+ req.on('data', c => body += c);
136
+ req.on('end', async () => {
137
+ try {
138
+ const { audioPath } = JSON.parse(body);
139
+ if (!audioPath || !existsSync(audioPath)) {
140
+ res.writeHead(400, { 'Content-Type': 'application/json' });
141
+ res.end(JSON.stringify({ error: 'invalid audio path' }));
142
+ return;
143
+ }
144
+ const r = spawnSync(SHERPA_BIN, [
145
+ '--tokens=' + MODEL_DIR + '/tokens.txt',
146
+ '--sense-voice-model=' + MODEL_DIR + '/model.int8.onnx',
147
+ '--num-threads=4', audioPath,
148
+ ], { windowsHide: true, timeout: 30000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
149
+ const all = (r.stdout || '') + '\n' + (r.stderr || '');
150
+ if (r.status !== 0) {
151
+ res.writeHead(500, { 'Content-Type': 'application/json' });
152
+ res.end(JSON.stringify({ error: 'sherpa exit ' + r.status }));
153
+ return;
154
+ }
155
+ const m = all.match(/"text"\s*:\s*"([^"]*)"/);
156
+ const text = (m && m[1]) ? m[1] : '';
157
+ res.writeHead(200, { 'Content-Type': 'application/json' });
158
+ res.end(JSON.stringify({ text }));
159
+ } catch (err) {
160
+ res.writeHead(500, { 'Content-Type': 'application/json' });
161
+ res.end(JSON.stringify({ error: String(err && err.message || err) }));
162
+ }
163
+ });
164
+ } else if (req.method === 'GET' && req.url === '/health') {
165
+ res.writeHead(200, { 'Content-Type': 'application/json' });
166
+ res.end(JSON.stringify({ status: 'ok' }));
167
+ } else {
168
+ res.writeHead(404); res.end();
169
+ }
170
+ });
171
+ server.listen(PORT, () => console.log('[ASR] listening on ' + PORT));
172
+ "@
173
+ $serviceFile = "$InstallDir\asr-service.js"
174
+ Set-Content -Path $serviceFile -Value $serviceScript -Encoding UTF8
175
+
176
+ # ---- 6. 注册 nssm 服务 ----
177
+ $nssm = Get-Command nssm -ErrorAction SilentlyContinue
178
+ if (-not $nssm) {
179
+ Write-Host " 未找到 nssm,请先安装 nssm(winget install nssm 或从 nssm.cc 下载)" -ForegroundColor Red
180
+ exit 1
181
+ }
182
+ $nodeExe = (Get-Command node).Source
183
+ nssm stop asr 2>$null | Out-Null
184
+ nssm remove asr confirm 2>$null | Out-Null
185
+ nssm install asr "$nodeExe" "$serviceFile" | Out-Null
186
+ nssm set asr AppDirectory "$InstallDir" | Out-Null
187
+ nssm set asr AppEnvironmentExtra "ASR_SERVICE_PORT=$Port" | Out-Null
188
+ nssm set asr Start SERVICE_AUTO_START | Out-Null
189
+ nssm set asr AppStdout "$InstallDir\asr-stdout.log" | Out-Null
190
+ nssm set asr AppStderr "$InstallDir\asr-stderr.log" | Out-Null
191
+ nssm start asr | Out-Null
192
+ Start-Sleep -Seconds 2
193
+
194
+ # ---- 7. 验证 ----
195
+ Write-Host "`n==== 安装完成,验证服务 ====" -ForegroundColor Cyan
196
+ try {
197
+ $health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 5
198
+ Write-Host " ASR 服务健康: $($health.status)" -ForegroundColor Green
199
+ } catch {
200
+ Write-Host " 服务未响应,请查看 $InstallDir\asr-stderr.log" -ForegroundColor Red
201
+ }
202
+ Write-Host "`n请到 dsh 设置 → 语音服务 → ASR,点「检测已安装」自动填入地址 http://127.0.0.1:$Port" -ForegroundColor Cyan
@@ -0,0 +1,161 @@
1
+ # ============================================================
2
+ # dsh-input-tools 本地 TTS 一键安装脚本(Windows)
3
+ # 安装内容:
4
+ # 1. sherpa-onnx(含 sherpa-onnx-offline-tts.exe 离线 TTS)
5
+ # 2. 中文 MeloTTS VITS 模型(vits-melo-tts-zh_en)
6
+ # 3. ffmpeg(无则用 winget 安装)
7
+ # 4. 生成 local-tts.mjs 启动脚本(与插件"本地命令"契约一致:
8
+ # node local-tts.mjs <文本> → stdout 输出 mp3 音频字节)
9
+ #
10
+ # 安装位置:自动放在本插件包目录下的 sherpa-onnx/(脚本位于
11
+ # <插件包>/scripts/install-local-tts.ps1,自动推导到 <插件包>/sherpa-onnx)
12
+ # ——与 ASR 安装共用同一目录,两个 exe 一次下载全有。
13
+ # 用法:以管理员身份打开 PowerShell,执行:
14
+ # powershell -ExecutionPolicy Bypass -File "<插件包>\scripts\install-local-tts.ps1"
15
+ # 装完后到 dsh 设置 → 语音服务 → 本地 TTS,把提示的命令填进「本地命令」。
16
+ # ============================================================
17
+ $ErrorActionPreference = "Stop"
18
+ $Version = "v1.13.6"
19
+
20
+ # ---- 0. 自动推导安装目录:脚本在 <插件包>/scripts/,安装到 <插件包>/sherpa-onnx/ ----
21
+ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
22
+ $PluginRoot = Split-Path -Parent $ScriptDir
23
+ $InstallDir = Join-Path $PluginRoot "sherpa-onnx"
24
+
25
+ Write-Host "==== dsh 本地 TTS 一键安装 ====" -ForegroundColor Cyan
26
+ Write-Host "插件包目录: $PluginRoot"
27
+ Write-Host "安装目录: $InstallDir"
28
+
29
+ # ---- 0b. 幂等保护:已完整安装则直接退出 ----
30
+ $exeExists = Test-Path "$InstallDir\bin\sherpa-onnx-offline-tts.exe"
31
+ $modelExists = Test-Path "$InstallDir\models\melo\model.onnx"
32
+ if ($exeExists -and $modelExists) {
33
+ Write-Host "`n检测到本机已完整安装 sherpa-onnx + MeloTTS 模型。" -ForegroundColor Green
34
+ Write-Host "无需重复下载(local-tts.mjs 仍会确保存在)。" -ForegroundColor Green
35
+ }
36
+
37
+ # ---- 1. 创建目录 ----
38
+ New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
39
+ New-Item -ItemType Directory -Force -Path "$InstallDir\models" | Out-Null
40
+ New-Item -ItemType Directory -Force -Path "$InstallDir\bin" | Out-Null
41
+ New-Item -ItemType Directory -Force -Path "$InstallDir\tmp" | Out-Null
42
+
43
+ # ---- 2. 检查 ffmpeg(合成后放大音量 + 转 mp3 必需)----
44
+ Write-Host "`n[1/3] 检查 ffmpeg..." -ForegroundColor Yellow
45
+ $ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
46
+ if (-not $ffmpeg) {
47
+ Write-Host " 未找到 ffmpeg,尝试 winget 安装(首次安装需同意条款)..." -ForegroundColor Yellow
48
+ try {
49
+ winget install --id Gyan.FFmpeg -e --accept-package-agreements --accept-source-agreements
50
+ $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
51
+ $ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
52
+ } catch {
53
+ Write-Host " winget 安装失败,请手动安装 ffmpeg 并加入 PATH" -ForegroundColor Red
54
+ exit 1
55
+ }
56
+ }
57
+ Write-Host " ffmpeg: $($ffmpeg.Source)" -ForegroundColor Green
58
+
59
+ # ---- 3. 下载 sherpa-onnx(与 ASR 共用;含 offline-tts.exe)----
60
+ Write-Host "`n[2/3] 下载 sherpa-onnx $Version ..." -ForegroundColor Yellow
61
+ $pkgUrl = "https://github.com/k2-fsa/sherpa-onnx/releases/download/$Version/sherpa-onnx-$Version-win-x64-shared-MD-Release.tar.bz2"
62
+ $pkgFile = "$InstallDir\tmp\sherpa-onnx.tar.bz2"
63
+ if (-not (Test-Path "$InstallDir\bin\sherpa-onnx-offline-tts.exe")) {
64
+ if (-not (Test-Path "$InstallDir\bin\sherpa-onnx-offline.exe")) {
65
+ if (-not (Test-Path $pkgFile)) {
66
+ Write-Host " 下载 $pkgUrl"
67
+ Invoke-WebRequest -Uri $pkgUrl -OutFile $pkgFile -UseBasicParsing
68
+ }
69
+ Write-Host " 解压..."
70
+ tar -xjf $pkgFile -C "$InstallDir\tmp"
71
+ $extracted = Get-ChildItem "$InstallDir\tmp" -Directory | Where-Object { $_.Name -like "sherpa-onnx-*win*" } | Select-Object -First 1
72
+ if ($extracted) {
73
+ Copy-Item "$($extracted.FullName)\bin\*" "$InstallDir\bin\" -Force
74
+ Copy-Item "$($extracted.FullName)\lib\*" "$InstallDir\lib\" -Force -ErrorAction SilentlyContinue
75
+ Write-Host " sherpa-onnx 解压完成" -ForegroundColor Green
76
+ } else {
77
+ Write-Host " 解压目录未找到,检查 tmp 目录" -ForegroundColor Red
78
+ exit 1
79
+ }
80
+ } else {
81
+ Write-Host " 检测到已有 sherpa-onnx(ASR 已装),离线 TTS exe 应同在 bin/ 下" -ForegroundColor Yellow
82
+ }
83
+ } else {
84
+ Write-Host " sherpa-onnx 已存在,跳过下载" -ForegroundColor Green
85
+ }
86
+
87
+ # ---- 4. 下载 MeloTTS 中文模型 ----
88
+ Write-Host "`n[3/3] 下载中文 MeloTTS VITS 模型..." -ForegroundColor Yellow
89
+ $modelDir = "$InstallDir\models\melo"
90
+ $modelUrl = "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-melo-tts-zh_en.tar.bz2"
91
+ $modelFile = "$InstallDir\tmp\melo.tar.bz2"
92
+ if (-not (Test-Path "$modelDir\model.onnx")) {
93
+ if (-not (Test-Path $modelFile)) {
94
+ Write-Host " 下载 $modelUrl"
95
+ Invoke-WebRequest -Uri $modelUrl -OutFile $modelFile -UseBasicParsing
96
+ }
97
+ New-Item -ItemType Directory -Force -Path $modelDir | Out-Null
98
+ Write-Host " 解压模型..."
99
+ tar -xjf $modelFile -C $modelDir --strip-components=1
100
+ Write-Host " 模型解压完成" -ForegroundColor Green
101
+ } else {
102
+ Write-Host " 模型已存在,跳过下载" -ForegroundColor Green
103
+ }
104
+
105
+ # ---- 5. 写 local-tts.mjs 启动脚本(契约:node local-tts.mjs <文本> → stdout 输出 mp3)----
106
+ # 注意:JS 里含反引号模板字符串,必须用单引号 here-string(@'...'@)字面生成,再替换路径占位符
107
+ Write-Host "`n写入 local-tts.mjs 启动脚本..." -ForegroundColor Yellow
108
+ $exePath = "$InstallDir\bin\sherpa-onnx-offline-tts.exe".Replace('\', '/')
109
+ $basePath = "$InstallDir\models\melo".Replace('\', '/')
110
+ $launcherTemplate = @'
111
+ // local-tts.mjs — 本地 MeloTTS(sherpa-onnx VITS 中文模型)TTS 包装
112
+ // 契约(配合 dsh-input-tools 的"本地命令"):文本作末参,stdout 输出音频字节(mp3)。
113
+ // 本文件由 install-local-tts.ps1 自动生成,勿手改;重装会重新生成。
114
+ import { execFileSync } from 'node:child_process'
115
+ import { readFileSync, unlinkSync } from 'node:fs'
116
+ import { join } from 'node:path'
117
+
118
+ const SHERPA = '__SHERPA__'
119
+ const BASE = '__BASE__'
120
+ const text = process.argv.slice(2).join(' ')
121
+ if (!text) {
122
+ console.error('local-tts: 缺少文本参数')
123
+ process.exit(1)
124
+ }
125
+ const tmp = join(process.env.TEMP ?? '/tmp', `dsh-local-tts-${process.pid}-${Date.now()}`)
126
+ const wav = `${tmp}.wav`
127
+ const mp3 = `${tmp}.mp3`
128
+ try {
129
+ execFileSync(SHERPA, [
130
+ '--vits-model=' + BASE + '/model.onnx',
131
+ '--vits-tokens=' + BASE + '/tokens.txt',
132
+ '--vits-lexicon=' + BASE + '/lexicon.txt',
133
+ '--vits-dict-dir=' + BASE + '/dict',
134
+ '--vits-length-scale=1.0',
135
+ '--output-filename=' + wav,
136
+ text,
137
+ ], { windowsHide: true, stdio: 'ignore', timeout: 120_000 })
138
+ // 本地合成音量偏小:ffmpeg 放大 4 倍并转 mp3
139
+ execFileSync('ffmpeg', ['-y', '-i', wav, '-af', 'volume=4.0', '-c:a', 'libmp3lame', '-b:a', '128k', mp3], {
140
+ windowsHide: true, stdio: 'ignore', timeout: 60_000,
141
+ })
142
+ process.stdout.write(readFileSync(mp3))
143
+ } finally {
144
+ try { unlinkSync(wav) } catch { /* 忽略 */ }
145
+ try { unlinkSync(mp3) } catch { /* 忽略 */ }
146
+ }
147
+ '@
148
+ $launcher = $launcherTemplate.Replace('__SHERPA__', $exePath).Replace('__BASE__', $basePath)
149
+ $launcherFile = "$PluginRoot\local-tts.mjs"
150
+ Set-Content -Path $launcherFile -Value $launcher -Encoding UTF8
151
+ Write-Host " local-tts.mjs: $launcherFile" -ForegroundColor Green
152
+
153
+ # ---- 6. 完成提示 ----
154
+ Write-Host "`n==== 安装完成 ====" -ForegroundColor Cyan
155
+ $nodeExe = (Get-Command node).Source
156
+ $cmdLine = "$nodeExe `"$launcherFile`""
157
+ Write-Host "请到 dsh 设置 → 语音服务 → 本地 TTS,把下面这行填进「本地命令」:"
158
+ Write-Host ""
159
+ Write-Host " $cmdLine" -ForegroundColor Green
160
+ Write-Host ""
161
+ Write-Host "然后点「试听本地 TTS」验证。若想用常驻 HTTP 模式,可自行加一层服务包装。" -ForegroundColor Cyan