@oadank/dsh-input-tools 0.3.15 → 0.3.17
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/assets/voiceclone-samples/8da38fcc-b041-4f5b-86b9-901956016f89-preview.mp3 +0 -0
- package/lib/client.js +265 -54
- package/lib/index.js +201 -91
- package/package.json +1 -1
- package/patches/dsh-voice-rc8.patch +1 -2
- package/scripts/gen-ttuan-preview.mjs +55 -0
- package/scripts/install-asr.ps1 +18 -11
- package/scripts/install-local-tts.ps1 +27 -12
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -484,9 +484,9 @@ window.__ModuleLoader__.load({
|
|
|
484
484
|
{ key: "lazy", label: "慵懒", ctx: "用慵懒、松弛的语气,语速慢悠悠,声音松散,气息不紧不慢,漫不经心" },
|
|
485
485
|
{ key: "deep", label: "深沉", ctx: "用深沉、厚重的语气,若有所思,语速稳中有顿挫,声音偏低,字字有分量" },
|
|
486
486
|
];
|
|
487
|
-
const ENGINES_ORDER = ["edge", "xiaomi", "voiceclone", "local", "ali"];
|
|
487
|
+
const ENGINES_ORDER = ["edge", "xiaomi", "voicedesign", "voiceclone", "local", "ali"];
|
|
488
488
|
const ENGINE_LABELS = {
|
|
489
|
-
edge: "微软 edge(免费)", xiaomi: "小米 MiMo", voiceclone: "小米克隆(VoiceClone)", local: "本地 TTS", ali: "阿里 qwen3-tts",
|
|
489
|
+
edge: "微软 edge(免费)", xiaomi: "小米 MiMo", voicedesign: "小米语音设计(VoiceDesign)", voiceclone: "小米克隆(VoiceClone)", local: "本地 TTS", ali: "阿里 qwen3-tts",
|
|
490
490
|
};
|
|
491
491
|
const MIMO_DOC_URL = "https://mimo.mi.com/models/zh-CN/mimo-v2.5-tts";
|
|
492
492
|
// VoiceDesign 官方示例(音色设计:Instruct=音色描述/导演指令,Text=要朗读的文本)
|
|
@@ -512,9 +512,11 @@ window.__ModuleLoader__.load({
|
|
|
512
512
|
// [本地改造 2026-08-21] 所有克隆音色的统一试听文本(与每个样本自己的风格指令配合,
|
|
513
513
|
// 试听时能同时听出"音色+个性";如小团团样本的指令让它念这句时自然带沙雕可爱腔)
|
|
514
514
|
const CLONE_PREVIEW_TEXT = "喂喂喂!你怎么才来呀?我都等你老半天啦!我跟你说啊——你今天可不能凶我哦,因为……因为你又不娶我,哼!不过嘛,看在你这么乖的份上,本小姐今天心情好,就大发慈悲原谅你啦!嘿嘿嘿~走吧走吧,出发喽!";
|
|
515
|
+
// [本地改造 2026-08-22] 自带默认样本 id(小团团):禁止删除、有预生成合成试听录音
|
|
516
|
+
const BUNDLED_CLONE_ID = "8da38fcc-b041-4f5b-86b9-901956016f89";
|
|
515
517
|
|
|
516
518
|
const vInput = {
|
|
517
|
-
background: "var(--dsw-specific-input-
|
|
519
|
+
background: "var(--dsw-specific-input-major,#ffffff)", color: "var(--dsw-alias-label-primary,#e6e9ef)",
|
|
518
520
|
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "6px",
|
|
519
521
|
padding: "6px 10px", fontSize: "12.5px", fontFamily: "inherit", width: "100%",
|
|
520
522
|
boxSizing: "border-box",
|
|
@@ -556,6 +558,9 @@ window.__ModuleLoader__.load({
|
|
|
556
558
|
const [rulesHover, setRulesHover] = useState(false);
|
|
557
559
|
const [cloneListTipPinned, setCloneListTipPinned] = useState(false);
|
|
558
560
|
const [cloneListTipHover, setCloneListTipHover] = useState(false);
|
|
561
|
+
// [本地改造 2026-08-22] 克隆音色「?」弹层:显示该音色默认沟通指令 + 试听文本(用户想看到,之前是隐藏的)
|
|
562
|
+
const [cloneInfoId, setCloneInfoId] = useState(null); // 当前展开信息的样本 id(hover 或 pinned)
|
|
563
|
+
const [cloneInfoPinned, setCloneInfoPinned] = useState(false);
|
|
559
564
|
const [designTipPinned, setDesignTipPinned] = useState(false);
|
|
560
565
|
const [designTipHover, setDesignTipHover] = useState(false);
|
|
561
566
|
const [asrTipPinned, setAsrTipPinned] = useState(false);
|
|
@@ -576,6 +581,9 @@ window.__ModuleLoader__.load({
|
|
|
576
581
|
const [asrResult, setAsrResult] = useState(null); // { ok, text, busy } | null
|
|
577
582
|
// [本地改造 2026-08-21] 克隆样本添加(选择音频 → 上传 → 命名)
|
|
578
583
|
const [cloneName, setCloneName] = useState("");
|
|
584
|
+
// [本地改造 2026-08-22] 添加克隆音色还需提供:指令(默认沟通语气)+ 文本(试听念的内容)
|
|
585
|
+
const [cloneContext, setCloneContext] = useState("");
|
|
586
|
+
const [clonePreviewText, setClonePreviewText] = useState("");
|
|
579
587
|
const [addingClone, setAddingClone] = useState(false);
|
|
580
588
|
const [cloneAddMsg, setCloneAddMsg] = useState(null); // { ok, text } | null
|
|
581
589
|
const cloneFileRef = useRef(null);
|
|
@@ -693,12 +701,17 @@ window.__ModuleLoader__.load({
|
|
|
693
701
|
name: cloneName.trim() !== "" ? cloneName.trim() : file.name.replace(/\.(mp3|wav)$/i, ""),
|
|
694
702
|
audioBase64: data,
|
|
695
703
|
mediaType: file.type || "audio/wav",
|
|
704
|
+
// [本地改造 2026-08-22] 提供 3 样:指令(默认沟通语气)+ 文本(试听内容)+ 样本音频
|
|
705
|
+
context: cloneContext,
|
|
706
|
+
previewText: clonePreviewText,
|
|
696
707
|
}),
|
|
697
708
|
});
|
|
698
709
|
const d = await r.json();
|
|
699
710
|
if (d?.ok) {
|
|
700
711
|
setCloneAddMsg({ ok: true, text: "已添加克隆音色「" + d.sample.name + "」,如需默认使用,在「默认语音引擎」选「小米克隆」即可" });
|
|
701
712
|
setCloneName("");
|
|
713
|
+
setCloneContext("");
|
|
714
|
+
setClonePreviewText("");
|
|
702
715
|
// [本地改造 2026-08-21] 以服务端返回的 config 为准刷新(含新增样本),避免本地拼装丢字段
|
|
703
716
|
if (d.config) setConfig(d.config);
|
|
704
717
|
} else {
|
|
@@ -724,6 +737,7 @@ window.__ModuleLoader__.load({
|
|
|
724
737
|
body: JSON.stringify({
|
|
725
738
|
engine, voice: voice ?? undefined, context: context ?? undefined, samplePath: samplePath ?? undefined,
|
|
726
739
|
text: extra?.text ?? undefined, cmd: extra?.cmd ?? undefined, url: extra?.url ?? undefined,
|
|
740
|
+
cloneContext: extra?.cloneContext ?? undefined, // [2026-08-22] 克隆试听可带样本自带指令
|
|
727
741
|
}),
|
|
728
742
|
})
|
|
729
743
|
.then((r) => r.json())
|
|
@@ -770,6 +784,33 @@ window.__ModuleLoader__.load({
|
|
|
770
784
|
} catch { if (previewTagRef.current === tag) setPreviewing(null); }
|
|
771
785
|
};
|
|
772
786
|
|
|
787
|
+
// [本地改造 2026-08-22] 播放合成试听录音:默认样本=预生成静态文件(免联网,和 VoiceDesign 官方示例同类);
|
|
788
|
+
// 没有预生成录音(自建样本)→ 回退在线合成,并带上该样本自己的指令/文本
|
|
789
|
+
const playBakedPreview = async (sp, tag) => {
|
|
790
|
+
if (previewRef.current !== null) { previewRef.current.pause(); previewRef.current = null; }
|
|
791
|
+
previewTagRef.current = tag;
|
|
792
|
+
setPreviewing(tag);
|
|
793
|
+
setPreviewErr(null);
|
|
794
|
+
try {
|
|
795
|
+
const r = await fetch("/voice-config/voice-clone/preview-sample?id=" + encodeURIComponent(sp.id));
|
|
796
|
+
const d = await r.json();
|
|
797
|
+
if (!d?.ok) {
|
|
798
|
+
previewTagRef.current = null;
|
|
799
|
+
setPreviewing(null);
|
|
800
|
+
previewVoice("voiceclone", undefined, undefined, sp.path, tag, {
|
|
801
|
+
text: (sp.previewText && sp.previewText.trim() !== "") ? sp.previewText : CLONE_PREVIEW_TEXT,
|
|
802
|
+
cloneContext: (sp.context && sp.context.trim() !== "") ? sp.context : "",
|
|
803
|
+
});
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const audio = new Audio("data:" + d.mediaType + ";base64," + d.data);
|
|
807
|
+
previewRef.current = audio;
|
|
808
|
+
audio.onended = () => { if (previewTagRef.current === tag) setPreviewing(null); };
|
|
809
|
+
audio.onerror = () => { if (previewTagRef.current === tag) setPreviewing(null); setPreviewErr("音频加载失败(试听录音可能已损坏)"); };
|
|
810
|
+
audio.play().catch(() => { if (previewTagRef.current === tag) setPreviewing(null); });
|
|
811
|
+
} catch (e) { if (previewTagRef.current === tag) setPreviewing(null); setPreviewErr(String(e?.message ?? e)); }
|
|
812
|
+
};
|
|
813
|
+
|
|
773
814
|
const previewBtn = (tag, label, onClick, icon) => h("button", {
|
|
774
815
|
type: "button", "aria-label": label, title: label,
|
|
775
816
|
style: {
|
|
@@ -886,7 +927,7 @@ window.__ModuleLoader__.load({
|
|
|
886
927
|
style: {
|
|
887
928
|
position: "absolute", ...(place === "top" ? { bottom: "calc(100% + 6px)" } : { top: "calc(100% + 6px)" }), zIndex: 60,
|
|
888
929
|
...(align === "right" ? { right: "0", left: "auto" } : align === "center" ? { left: "50%", transform: "translateX(-50%)" } : { left: "0", right: "auto" }),
|
|
889
|
-
background: "var(--dsw-specific-input-
|
|
930
|
+
background: "var(--dsw-specific-input-major,#ffffff)",
|
|
890
931
|
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
891
932
|
padding: "10px 12px", boxShadow: "0 8px 24px rgba(0,0,0,.35)",
|
|
892
933
|
fontSize: "12px", lineHeight: "1.7", color: "var(--dsw-alias-label-secondary,#9aa3ad)",
|
|
@@ -894,6 +935,41 @@ window.__ModuleLoader__.load({
|
|
|
894
935
|
},
|
|
895
936
|
}, text) : null,
|
|
896
937
|
);
|
|
938
|
+
// [本地改造 2026-08-22] 克隆音色「?」:上方弹出、向右展开,展示“默认沟通指令”与“试听文本”,让用户直观看到该克隆音默认用什么语气沟通、试听念的是哪句
|
|
939
|
+
const cloneInfoTip = (sp) => {
|
|
940
|
+
const active = cloneInfoId === sp.id;
|
|
941
|
+
const instruct = (sp.context && sp.context.trim() !== "") ? sp.context.trim() : "(该样本未单独设置指令,使用全局默认指令)";
|
|
942
|
+
return h("span", { style: { position: "relative", display: "inline-flex", alignItems: "center", flex: "none" } },
|
|
943
|
+
h("button", {
|
|
944
|
+
type: "button", "aria-label": "查看语音指令与试听文本", title: "查看语音指令与试听文本",
|
|
945
|
+
style: {
|
|
946
|
+
border: "none", borderRadius: "999px", width: "18px", height: "18px", padding: "0",
|
|
947
|
+
background: (active && cloneInfoPinned) ? "var(--vk-accent,#4b6fff)" : "rgba(128,128,128,.15)",
|
|
948
|
+
color: "inherit", cursor: "pointer", fontSize: "10px", fontWeight: 700,
|
|
949
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
950
|
+
},
|
|
951
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
952
|
+
onMouseEnter: () => setCloneInfoId(sp.id),
|
|
953
|
+
onMouseLeave: () => { if (!cloneInfoPinned) setCloneInfoId(null); },
|
|
954
|
+
onClick: () => { const willPin = !(cloneInfoId === sp.id && cloneInfoPinned); setCloneInfoPinned(willPin); setCloneInfoId(willPin ? sp.id : null); },
|
|
955
|
+
}, "?"),
|
|
956
|
+
active ? h("div", {
|
|
957
|
+
style: {
|
|
958
|
+
position: "absolute", bottom: "calc(100% + 6px)", left: "0", right: "auto", zIndex: 70,
|
|
959
|
+
background: "var(--dsw-specific-input-major,#ffffff)",
|
|
960
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
961
|
+
padding: "10px 12px", boxShadow: "0 8px 24px rgba(0,0,0,.35)",
|
|
962
|
+
fontSize: "12px", lineHeight: "1.7", color: "var(--dsw-alias-label-secondary,#9aa3ad)",
|
|
963
|
+
minWidth: "340px", maxWidth: "460px", textAlign: "left",
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
h("div", { style: { fontSize: "12px", fontWeight: 700, color: "var(--dsw-alias-label-primary,#e6e9ef)", marginBottom: "3px" } }, "默认沟通指令(" + (sp.name ?? "样本") + ")"),
|
|
967
|
+
h("div", { style: { marginBottom: "8px" } }, instruct),
|
|
968
|
+
h("div", { style: { fontSize: "12px", fontWeight: 700, color: "var(--dsw-alias-label-primary,#e6e9ef)", marginBottom: "3px" } }, "试听文本"),
|
|
969
|
+
h("div", { style: {} }, (sp.previewText && sp.previewText.trim() !== "") ? sp.previewText : CLONE_PREVIEW_TEXT),
|
|
970
|
+
) : null,
|
|
971
|
+
);
|
|
972
|
+
};
|
|
897
973
|
|
|
898
974
|
if (config === null) {
|
|
899
975
|
return h("div", { style: { padding: "16px", fontSize: "13px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "语音配置加载中…");
|
|
@@ -901,22 +977,90 @@ window.__ModuleLoader__.load({
|
|
|
901
977
|
const eng = config.engines;
|
|
902
978
|
const cloneSamples = Array.isArray(eng.voiceclone.samples) ? eng.voiceclone.samples : [];
|
|
903
979
|
const showRules = rulesPinned || rulesHover;
|
|
980
|
+
// [本地改造 2026-08-22] 语音设计单选模型:官方示例(asmr/docu/elder) / 自定义(custom) / 交给 AI(ai)
|
|
981
|
+
const VD_KEYS = ["asmr", "docu", "elder"];
|
|
982
|
+
const vdMode = (eng.voicedesign?.mode && ["asmr", "docu", "elder", "custom", "ai"].includes(eng.voicedesign.mode))
|
|
983
|
+
? eng.voicedesign.mode
|
|
984
|
+
: (() => {
|
|
985
|
+
const ctx = eng.voicedesign?.context ?? "";
|
|
986
|
+
const i = VOICE_DESIGN_EXAMPLES.findIndex((ex) => ex.instruct === ctx);
|
|
987
|
+
return i >= 0 ? VD_KEYS[i] : (ctx.trim() !== "" ? "custom" : "ai");
|
|
988
|
+
})();
|
|
989
|
+
const pickVdMode = (m) => {
|
|
990
|
+
// [2026-08-22] 单选切换:示例=写死指令+关 AI 情绪;custom=保留文本+关 AI 情绪;ai=开 AI 情绪
|
|
991
|
+
if (m === "ai") setEngine("voicedesign", { mode: "ai", emotion: true }, true);
|
|
992
|
+
else if (VD_KEYS.includes(m)) {
|
|
993
|
+
const idx = VD_KEYS.indexOf(m);
|
|
994
|
+
setEngine("voicedesign", { mode: m, context: VOICE_DESIGN_EXAMPLES[idx].instruct, emotion: false }, true);
|
|
995
|
+
} else {
|
|
996
|
+
setEngine("voicedesign", { mode: "custom", emotion: false }, true);
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
// [2026-08-22] 年龄感 6 档(婴儿感~老年感),锚点实时可改,禁止自由文本
|
|
1000
|
+
const AI_AGE_LABELS = { infant: "婴儿感", child: "幼儿感", teen: "少年感", young: "青年感", middle: "中年感", old: "老年感" };
|
|
1001
|
+
const normalizeAiAge = (v) => {
|
|
1002
|
+
if (!v) return "young";
|
|
1003
|
+
if (AI_AGE_LABELS[v] !== undefined) return v;
|
|
1004
|
+
const s = String(v);
|
|
1005
|
+
if (/婴/.test(s)) return "infant";
|
|
1006
|
+
if (/幼|小|岁\s*[0-6]|[0-6]\s*岁/.test(s)) return "child";
|
|
1007
|
+
if (/老/.test(s)) return "old";
|
|
1008
|
+
if (/中/.test(s)) return "middle";
|
|
1009
|
+
if (/少|[1][0-9]\s*岁|岁\s*[7-9]/.test(s)) return "teen";
|
|
1010
|
+
return "young";
|
|
1011
|
+
};
|
|
1012
|
+
// [2026-08-22] AI 自动模式的稳定锚点行:checkbox + 值控件。
|
|
1013
|
+
// optionsOrPlaceholder: null=无具体值可选(如音色质感);数组=[v,l][] 渲染 select;字符串=自由文本输入(placeholder)
|
|
1014
|
+
const vdLockRow = (label, keyName, value, onValue, optionsOrPlaceholder) => h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } },
|
|
1015
|
+
h("label", { style: { display: "inline-flex", alignItems: "center", gap: "5px", cursor: "pointer" } },
|
|
1016
|
+
h("input", { type: "checkbox", checked: eng.voicedesign?.[keyName] === true, onChange: (e) => setEngine("voicedesign", { [keyName]: e.target.checked }, true), style: { accentColor: "var(--vk-accent,#4b6fff)", cursor: "pointer", width: "13px", height: "13px" } }),
|
|
1017
|
+
label),
|
|
1018
|
+
Array.isArray(optionsOrPlaceholder) && eng.voicedesign?.[keyName] === true ? h("select", {
|
|
1019
|
+
value: value,
|
|
1020
|
+
onChange: (e) => onValue(e.target.value),
|
|
1021
|
+
// [2026-08-22] 修复: 之前 onMouseDown preventDefault 会禁掉原生下拉弹出, 导致固定性别选不了
|
|
1022
|
+
style: { ...vInput, width: "auto", padding: "3px 8px", fontSize: "12px" },
|
|
1023
|
+
}, optionsOrPlaceholder.map(([v, l]) => h("option", { key: v, value: v }, l))) : null,
|
|
1024
|
+
typeof optionsOrPlaceholder === "string" && eng.voicedesign?.[keyName] === true ? h("input", {
|
|
1025
|
+
type: "text", value: value, placeholder: optionsOrPlaceholder,
|
|
1026
|
+
onChange: (e) => onValue(e.target.value),
|
|
1027
|
+
style: { ...vInput, width: "120px", padding: "3px 8px", fontSize: "12px" },
|
|
1028
|
+
}) : null,
|
|
1029
|
+
optionsOrPlaceholder === null ? h("span", { style: { fontSize: "11px", opacity: .8 } }, "(保持同一质感)") : null,
|
|
1030
|
+
);
|
|
904
1031
|
// [本地改造 2026-08-21] 已移除 VoiceClone/VoiceDesign 勾选:分区始终显示
|
|
905
1032
|
const designOn = false;
|
|
906
1033
|
const cloneOn = false;
|
|
907
1034
|
|
|
908
1035
|
return h("div", { style: { display: "flex", flexDirection: "column", gap: "14px", padding: "16px", width: "100%", boxSizing: "border-box" } },
|
|
909
|
-
//
|
|
910
|
-
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", fontSize: "15px", fontWeight: 700, color: "var(--dsw-alias-label-primary,#e6e9ef)" } },
|
|
911
|
-
"语音服务"
|
|
912
|
-
|
|
1036
|
+
// 分区标题(语音图标已移到各服务商卡片前)+ 仓库链接(内联,不换行)
|
|
1037
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap", fontSize: "15px", fontWeight: 700, color: "var(--dsw-alias-label-primary,#e6e9ef)" } },
|
|
1038
|
+
"语音服务",
|
|
1039
|
+
h("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: "12px", fontWeight: 400, color: "var(--dsw-alias-label-secondary,#9aa3b2)" } },
|
|
1040
|
+
h("a", {
|
|
1041
|
+
href: "https://github.com/oadank/dsh-input-tools",
|
|
1042
|
+
target: "_blank", rel: "noopener",
|
|
1043
|
+
title: "语音插件源码仓库(dsh-input-tools)",
|
|
1044
|
+
style: { color: "var(--dsw-alias-link,#5b9cff)", textDecoration: "none" },
|
|
1045
|
+
}, "语音插件仓库 ↗"),
|
|
1046
|
+
h("span", { style: { color: "var(--dsw-alias-label-tertiary,#6b7384)" } }, "·"),
|
|
1047
|
+
h("a", {
|
|
1048
|
+
href: "https://github.com/oadank/deepseek-harness",
|
|
1049
|
+
target: "_blank", rel: "noopener",
|
|
1050
|
+
title: "整合版:插件已内置,一键安装,推荐大多数用户",
|
|
1051
|
+
style: { color: "var(--dsw-alias-link,#5b9cff)", textDecoration: "none" },
|
|
1052
|
+
}, "整合版(推荐)↗"),
|
|
1053
|
+
),
|
|
1054
|
+
),
|
|
1055
|
+
// [2026-08-21] 试听失败错误提示;[2026-08-22] fixed 顶部弹窗 Toast + 限高滚动(错误堆栈超长不撑爆)
|
|
913
1056
|
previewErr !== null ? h("div", {
|
|
914
1057
|
style: {
|
|
915
1058
|
position: "fixed", top: "24px", left: "50%", transform: "translateX(-50%)", zIndex: 9999,
|
|
916
1059
|
background: "rgba(229,72,77,.95)", color: "#fff", borderRadius: "10px",
|
|
917
1060
|
padding: "10px 18px", fontSize: "13px", lineHeight: "1.5",
|
|
918
|
-
boxShadow: "0 6px 24px rgba(0,0,0,.45)",
|
|
919
|
-
|
|
1061
|
+
boxShadow: "0 6px 24px rgba(0,0,0,.45)",
|
|
1062
|
+
maxWidth: "520px", maxHeight: "45vh", overflowY: "auto",
|
|
1063
|
+
whiteSpace: "pre-wrap", wordBreak: "break-word", pointerEvents: "none",
|
|
920
1064
|
},
|
|
921
1065
|
}, "试听失败:" + previewErr) : null,
|
|
922
1066
|
// [2026-08-21] 语音能力状态面板(安装即用 vs dsh 原生契约支持)
|
|
@@ -1035,6 +1179,10 @@ window.__ModuleLoader__.load({
|
|
|
1035
1179
|
// [本地改造 2026-08-21] 修复:defaultEngine 之前只改本地 state 不持久化,刷新回 auto;
|
|
1036
1180
|
// 现在与其它字段一致:防抖 POST 立即保存
|
|
1037
1181
|
const next = { ...config, defaultEngine: e.target.value };
|
|
1182
|
+
// [本地改造 2026-08-22] 选「语音设计」时若还没选过模式,默认「纪录片旁白」;用户自己切过就保留原设计
|
|
1183
|
+
if (e.target.value === "voicedesign" && !(config.engines?.voicedesign?.mode)) {
|
|
1184
|
+
next.engines = { ...(config.engines ?? {}), voicedesign: { ...(config.engines?.voicedesign ?? {}), mode: "docu", context: VOICE_DESIGN_EXAMPLES[1].instruct, emotion: false } };
|
|
1185
|
+
}
|
|
1038
1186
|
setConfig(next);
|
|
1039
1187
|
if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
|
|
1040
1188
|
saveTimerRef.current = window.setTimeout(() => {
|
|
@@ -1045,9 +1193,11 @@ window.__ModuleLoader__.load({
|
|
|
1045
1193
|
},
|
|
1046
1194
|
["auto", ...ENGINES_ORDER].map((k) => h("option", { key: k, value: k },
|
|
1047
1195
|
k === "auto" ? "auto(按规则自动选择,未启用任何引擎时用微软 edge 免费兜底)"
|
|
1048
|
-
: k === "
|
|
1049
|
-
? "
|
|
1050
|
-
:
|
|
1196
|
+
: k === "voicedesign"
|
|
1197
|
+
? "小米语音设计(VoiceDesign):默认用「纪录片旁白」指令"
|
|
1198
|
+
: k === "voiceclone"
|
|
1199
|
+
? "小米克隆(VoiceClone)" + (cloneSamples.length > 0 ? ":默认用「" + cloneSamples[0].name + "」" : "(未添加样本)")
|
|
1200
|
+
: ENGINE_LABELS[k])))),
|
|
1051
1201
|
// 语音三原则:问号按钮(hover 显示,点击固定/收起)
|
|
1052
1202
|
h("div", { style: { position: "relative", display: "inline-flex", alignItems: "center", gap: "6px" } },
|
|
1053
1203
|
h("button", {
|
|
@@ -1067,7 +1217,7 @@ window.__ModuleLoader__.load({
|
|
|
1067
1217
|
showRules ? h("div", {
|
|
1068
1218
|
style: {
|
|
1069
1219
|
position: "absolute", top: "calc(100% + 6px)", left: "0", zIndex: 30,
|
|
1070
|
-
background: "var(--dsw-specific-input-
|
|
1220
|
+
background: "var(--dsw-specific-input-major,#ffffff)",
|
|
1071
1221
|
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
1072
1222
|
padding: "10px 12px", boxShadow: "0 8px 24px rgba(0,0,0,.35)",
|
|
1073
1223
|
fontSize: "12px", lineHeight: "1.8", color: "var(--dsw-alias-label-secondary,#9aa3ad)",
|
|
@@ -1117,32 +1267,76 @@ window.__ModuleLoader__.load({
|
|
|
1117
1267
|
)),
|
|
1118
1268
|
),
|
|
1119
1269
|
),
|
|
1120
|
-
//
|
|
1270
|
+
// [2026-08-22] 语音设计:MiMo-V2.5-TTS-VoiceDesign(单选:官方示例 / 自定义 / 交给 AI,始终显示)
|
|
1121
1271
|
h("div", { style: { display: "flex", flexDirection: "column", gap: "8px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
1122
1272
|
h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
|
|
1123
|
-
h("span", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "
|
|
1124
|
-
helpTip("「音色设计 VoiceDesign
|
|
1273
|
+
h("span", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "语音设计:MiMo-V2.5-TTS-VoiceDesign"),
|
|
1274
|
+
helpTip("「音色设计 VoiceDesign」用一段文字描述你想要的声音(性别/年龄/质感/语速/情绪),AI 照着念。单选:选官方示例(ASMR / 纪录片旁白 / 年迈老先生),或自定义填写,或「交给 AI 自动发挥」(AI 按对话情境写音色描述,可勾选固定性别/音色/年龄保持声音稳定——尚未充分测试)。选为默认语音引擎后默认用「纪录片旁白」;切换过就保留你的选择。", designTipPinned, setDesignTipPinned, designTipHover, setDesignTipHover, "center", "top"),
|
|
1125
1275
|
),
|
|
1126
|
-
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "
|
|
1127
|
-
VOICE_DESIGN_EXAMPLES.map((ex, i) =>
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1276
|
+
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "单选:当前使用的高亮(点播放可试听)"),
|
|
1277
|
+
VOICE_DESIGN_EXAMPLES.map((ex, i) => {
|
|
1278
|
+
const key = VD_KEYS[i];
|
|
1279
|
+
const active = vdMode === key;
|
|
1280
|
+
return h("div", { key: ex.title, style: { border: "1px solid " + (active ? "var(--vk-accent,#4b6fff)" : "var(--dsw-alias-border-l1,#333a45)"), borderRadius: "8px", padding: "6px 10px", display: "flex", flexDirection: "column", gap: "6px", background: active ? "rgba(75,111,255,.08)" : "transparent" } },
|
|
1281
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" } },
|
|
1282
|
+
h("input", { type: "radio", name: "vd-mode", checked: active, onChange: () => pickVdMode(key), style: { accentColor: "var(--vk-accent,#4b6fff)", cursor: "pointer", flex: "none", width: "14px", height: "14px" } }),
|
|
1283
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)", flex: "none" } }, ex.title),
|
|
1284
|
+
active ? h("span", { style: { fontSize: "11px", color: "var(--vk-accent,#4b6fff)", flex: "none" } }, "使用中") : null,
|
|
1285
|
+
h("span", { style: { flex: 1 } }),
|
|
1286
|
+
helpTip(
|
|
1287
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } },
|
|
1288
|
+
h("div", null, h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "Instruct:"), ex.instruct),
|
|
1289
|
+
h("div", null, h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "Text:"), ex.text),
|
|
1290
|
+
),
|
|
1291
|
+
vdExamplePins[i], (v) => { const n = [...vdExamplePins]; n[i] = v; setVdExamplePins(n); },
|
|
1292
|
+
vdExampleHovers[i], (v) => { const n = [...vdExampleHovers]; n[i] = v; setVdExampleHovers(n); },
|
|
1293
|
+
"left", "top",
|
|
1134
1294
|
),
|
|
1135
|
-
vdExamplePins[i], (v) => { const n = [...vdExamplePins]; n[i] = v; setVdExamplePins(n); },
|
|
1136
|
-
vdExampleHovers[i], (v) => { const n = [...vdExampleHovers]; n[i] = v; setVdExampleHovers(n); },
|
|
1137
|
-
"left", "top",
|
|
1138
1295
|
),
|
|
1296
|
+
h("audio", {
|
|
1297
|
+
controls: true, preload: "none",
|
|
1298
|
+
src: vdSamples[i] !== undefined ? "data:" + vdSamples[i].mediaType + ";base64," + vdSamples[i].data : undefined,
|
|
1299
|
+
style: { width: "100%", height: "32px" },
|
|
1300
|
+
}),
|
|
1301
|
+
);
|
|
1302
|
+
}),
|
|
1303
|
+
// 自定义音色描述(单选)
|
|
1304
|
+
h("div", { style: { border: "1px solid " + (vdMode === "custom" ? "var(--vk-accent,#4b6fff)" : "var(--dsw-alias-border-l1,#333a45)"), borderRadius: "8px", padding: "6px 10px", display: "flex", flexDirection: "column", gap: "6px", background: vdMode === "custom" ? "rgba(75,111,255,.08)" : "transparent" } },
|
|
1305
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" } },
|
|
1306
|
+
h("input", { type: "radio", name: "vd-mode", checked: vdMode === "custom", onChange: () => pickVdMode("custom"), style: { accentColor: "var(--vk-accent,#4b6fff)", cursor: "pointer", flex: "none", width: "14px", height: "14px" } }),
|
|
1307
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "自定义音色描述"),
|
|
1308
|
+
vdMode === "custom" ? h("span", { style: { fontSize: "11px", color: "var(--vk-accent,#4b6fff)" } }, "使用中") : null,
|
|
1139
1309
|
),
|
|
1140
|
-
h("
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1310
|
+
vdMode === "custom" ? h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } },
|
|
1311
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
|
|
1312
|
+
previewBtn("vd-custom", "试听当前指令", () => previewVoice("voicedesign", undefined, eng.voicedesign?.context ?? "", undefined, "vd-custom", { text: "这是一段使用你设计的音色朗读的语音,用来检查当前音色描述的效果。" })),
|
|
1313
|
+
h("span", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "写好后点试听;切换到其它选项会保留这段文本"),
|
|
1314
|
+
),
|
|
1315
|
+
h("textarea", {
|
|
1316
|
+
value: eng.voicedesign?.context ?? "",
|
|
1317
|
+
onChange: (e) => setEngine("voicedesign", { context: e.target.value }, true),
|
|
1318
|
+
placeholder: "如:一位温柔的年轻女性,说标准普通话,语速缓慢,声音甜美,像在耳边轻声细语…",
|
|
1319
|
+
style: { ...vInput, minHeight: "64px", resize: "vertical", lineHeight: "1.6" },
|
|
1320
|
+
}),
|
|
1321
|
+
) : null,
|
|
1322
|
+
),
|
|
1323
|
+
// 交给 AI 自动发挥(单选)+ 稳定锚点锁定
|
|
1324
|
+
h("div", { style: { border: "1px solid " + (vdMode === "ai" ? "var(--vk-accent,#4b6fff)" : "var(--dsw-alias-border-l1,#333a45)"), borderRadius: "8px", padding: "6px 10px", display: "flex", flexDirection: "column", gap: "6px", background: vdMode === "ai" ? "rgba(75,111,255,.08)" : "transparent" } },
|
|
1325
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" } },
|
|
1326
|
+
h("input", { type: "radio", name: "vd-mode", checked: vdMode === "ai", onChange: () => pickVdMode("ai"), style: { accentColor: "var(--vk-accent,#4b6fff)", cursor: "pointer", flex: "none", width: "14px", height: "14px" } }),
|
|
1327
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "交给 AI 自动发挥"),
|
|
1328
|
+
vdMode === "ai" ? h("span", { style: { fontSize: "11px", color: "var(--vk-accent,#4b6fff)" } }, "使用中") : null,
|
|
1329
|
+
),
|
|
1330
|
+
vdMode === "ai" ? h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", lineHeight: "1.6" } },
|
|
1331
|
+
h("div", null, "音色描述由 AI 根据对话情境自动编写(任务成功兴奋道喜 / 生气委屈道歉 / 难过温柔安慰)。下面的锁定项让 AI 每次都是同一个人:性别/年龄选好值,音色质感保持同一质感,只允许情绪/语速/语气波动(尚未充分测试):"),
|
|
1332
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "4px" } },
|
|
1333
|
+
vdLockRow("固定性别", "lockGender", eng.voicedesign?.aiGender ?? "female", (v) => setEngine("voicedesign", { aiGender: v }, true), [["female", "女"], ["male", "男"]]),
|
|
1334
|
+
vdLockRow("固定音色质感", "lockTimbre", null, null, null),
|
|
1335
|
+
vdLockRow("固定年龄感", "lockAge", normalizeAiAge(eng.voicedesign?.aiAge), (v) => setEngine("voicedesign", { aiAge: v }, true),
|
|
1336
|
+
[["infant", "婴儿感"], ["child", "幼儿感"], ["teen", "少年感"], ["young", "青年感"], ["middle", "中年感"], ["old", "老年感"]]),
|
|
1337
|
+
),
|
|
1338
|
+
) : null,
|
|
1339
|
+
),
|
|
1146
1340
|
),
|
|
1147
1341
|
// 克隆模型:MiMo-V2.5-TTS-VoiceClone(样本管理,始终显示)
|
|
1148
1342
|
h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
@@ -1151,33 +1345,50 @@ window.__ModuleLoader__.load({
|
|
|
1151
1345
|
helpTip("克隆音色与预置音色(冰糖等)互斥:在「默认语音引擎」里选择「小米克隆(VoiceClone)」后,默认回复一律使用下方克隆声音;开启 VoiceDesign 时,AI 会在克隆底嗓上叠加情感指令(如「用委屈撒娇的语气」),克隆声同样带情感。", cloneListTipPinned, setCloneListTipPinned, cloneListTipHover, setCloneListTipHover, "center", "top"),
|
|
1152
1346
|
),
|
|
1153
1347
|
cloneSamples.length > 0 ? h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } },
|
|
1154
|
-
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "
|
|
1155
|
-
cloneSamples.map((sp) =>
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
style: {
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1348
|
+
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "已保存的克隆音色(默认语音引擎选「小米克隆」后用第一个音色):"),
|
|
1349
|
+
cloneSamples.map((sp) => {
|
|
1350
|
+
// [本地改造 2026-08-22] 自带小团团样本:禁止删除;两行展示(第一行 ?+名称+完整路径+试听原音,第二行 合成试听录音+删除)
|
|
1351
|
+
const isDefault = sp.id === BUNDLED_CLONE_ID;
|
|
1352
|
+
return h("div", { key: sp.id, style: { border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px", padding: "6px 10px", display: "flex", flexDirection: "column", gap: "6px", fontSize: "12.5px" } },
|
|
1353
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
|
|
1354
|
+
cloneInfoTip(sp),
|
|
1355
|
+
h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)", flex: "none", whiteSpace: "nowrap" } }, sp.name ?? "样本"),
|
|
1356
|
+
h("span", { style: { color: "var(--dsw-alias-label-secondary,#9aa3ad)", fontSize: "11px", flex: 1, minWidth: 0, wordBreak: "break-all", lineHeight: "1.4" } }, sp.path ?? ""),
|
|
1357
|
+
previewBtn("clone-src:" + sp.id, "试听原音(样本原始音频,对比还原度)", () => previewSourceVoice(sp.path, "clone-src:" + sp.id), "▶"),
|
|
1358
|
+
),
|
|
1359
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
|
|
1360
|
+
previewBtn("clone-baked:" + sp.id, "播放合成音(克隆效果试听)", () => playBakedPreview(sp, "clone-baked:" + sp.id)),
|
|
1361
|
+
h("span", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: 1 } },
|
|
1362
|
+
"合成效果试听" + (isDefault ? "(预生成录音,免联网)" : "(按该音色指令/文本合成)")),
|
|
1363
|
+
isDefault ? null : h("button", {
|
|
1364
|
+
type: "button", "aria-label": "删除", title: "删除此克隆音色",
|
|
1365
|
+
style: { border: "none", borderRadius: "6px", width: "28px", height: "28px", flex: "none", background: "rgba(229,72,77,.15)", color: "#e5484d", cursor: "pointer", fontSize: "14px" },
|
|
1366
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1367
|
+
onClick: () => setEngine("voiceclone", { samples: cloneSamples.filter((x) => x.id !== sp.id) }, true),
|
|
1368
|
+
}, "✕"),
|
|
1369
|
+
),
|
|
1370
|
+
);
|
|
1371
|
+
}),
|
|
1167
1372
|
) : h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", lineHeight: 1.7 } },
|
|
1168
1373
|
"无(尚未添加克隆音色)。",
|
|
1169
1374
|
),
|
|
1170
1375
|
// [本地改造 2026-08-21] 添加克隆音色:选音频 → 命名 → 上传
|
|
1376
|
+
// [本地改造 2026-08-22] 与自带小团团样本对齐:需要提供 3 样 —— 指令(默认沟通语气)+ 文本(试听内容)+ 样本音频
|
|
1171
1377
|
h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
1172
1378
|
h("div", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "添加克隆音色"),
|
|
1173
|
-
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "
|
|
1174
|
-
h("div", { style: { display: "flex",
|
|
1379
|
+
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "需要提供 3 样:①沟通指令(这个声音默认用什么语气跟客户沟通)②试听文本(点播放念哪句)③样本音频(克隆的原始声音)。音频支持 mp3 / wav,Base64 后 ≤10MB(官方限制);参考语音建议 15-60 秒、单人纯人声无背景音乐,越长克隆越准。"),
|
|
1380
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } },
|
|
1381
|
+
vField("沟通指令(默认语气)", h("textarea", { value: cloneContext, onChange: (e) => setCloneContext(e.target.value), placeholder: "如:一个魔性的少女萝莉音,说话自带沙雕搞怪气质,爱撒娇爱耍宝…", style: { ...vInput, minHeight: "56px", resize: "vertical", lineHeight: "1.5" } })),
|
|
1382
|
+
vField("试听文本", h("textarea", { value: clonePreviewText, onChange: (e) => setClonePreviewText(e.target.value), placeholder: "如:喂喂喂!你怎么才来呀?我都等你老半天啦!……", style: { ...vInput, minHeight: "56px", resize: "vertical", lineHeight: "1.5" } })),
|
|
1175
1383
|
vField("名称", h("input", { value: cloneName, onChange: (e) => setCloneName(e.target.value), placeholder: "如:我的声音(留空用文件名)", style: { ...vInput, width: "100%" } })),
|
|
1176
|
-
h("
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1384
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" } },
|
|
1385
|
+
h("button", {
|
|
1386
|
+
type: "button", onClick: () => cloneFileRef.current?.click(), disabled: addingClone,
|
|
1387
|
+
style: { background: "var(--vk-accent,#4b6fff)", color: "#fff", border: "none", borderRadius: "999px", padding: "7px 16px", fontSize: "12.5px", fontWeight: 600, cursor: "pointer", flex: "none" },
|
|
1388
|
+
}, addingClone ? "添加中…" : "选择音频文件添加"),
|
|
1389
|
+
h("span", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "选完音频即自动上传添加"),
|
|
1390
|
+
h("input", { ref: cloneFileRef, type: "file", accept: ".mp3,.wav,audio/mpeg,audio/wav", style: { display: "none" }, onChange: (e) => { const f = e.target.files && e.target.files[0]; if (f !== undefined && f !== null) void addCloneSample(f); } }),
|
|
1391
|
+
),
|
|
1181
1392
|
),
|
|
1182
1393
|
cloneAddMsg !== null ? h("div", { style: { fontSize: "12px", color: cloneAddMsg.ok ? "#73c991" : "#f14c4c" } }, cloneAddMsg.text) : null,
|
|
1183
1394
|
),
|
package/lib/index.js
CHANGED
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { createHash, randomUUID } from 'node:crypto'
|
|
23
|
-
import { mkdir, open, readFile, unlink, writeFile, copyFile } from 'node:fs/promises'
|
|
23
|
+
import { mkdir, open, readFile, unlink, writeFile, copyFile, stat } from 'node:fs/promises'
|
|
24
24
|
import { constants, readFileSync } from 'node:fs'
|
|
25
25
|
import { homedir } from 'node:os'
|
|
26
26
|
import { fileURLToPath } from 'node:url'
|
|
27
|
-
import { join, resolve } from 'node:path'
|
|
27
|
+
import { join, resolve, sep } from 'node:path'
|
|
28
28
|
import { execFileSync } from 'node:child_process'
|
|
29
29
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
30
30
|
import { edgeTts } from './edge-tts.js'
|
|
@@ -48,6 +48,9 @@ const BUNDLED_CLONE_SAMPLE = {
|
|
|
48
48
|
}
|
|
49
49
|
const VOICE_DESIGN_SAMPLE_KEYS = ['asmr', 'docu', 'elder']
|
|
50
50
|
|
|
51
|
+
// [2026-08-22] AI 自动模式的年龄感 6 档(用户实时可改,禁止自由文本)
|
|
52
|
+
const AI_AGE_LABELS = { infant: '婴儿感', child: '幼儿感', teen: '少年感', young: '青年感', middle: '中年感', old: '老年感' }
|
|
53
|
+
|
|
51
54
|
let bundledInitDone = false
|
|
52
55
|
/** 首次加载把自带素材落地到 DSH_HOME:克隆样本 mp3 拷贝 + 首次安装自动注册小团团。 */
|
|
53
56
|
async function ensureBundledAssets(config, parsed) {
|
|
@@ -60,6 +63,8 @@ async function ensureBundledAssets(config, parsed) {
|
|
|
60
63
|
try {
|
|
61
64
|
await mkdir(cloneDir, { recursive: true })
|
|
62
65
|
await copyFile(join(ASSETS_DIR, 'voiceclone-samples', BUNDLED_CLONE_ID + '.mp3'), dstClone)
|
|
66
|
+
// [2026-08-22] 预生成的合成试听录音(静态文件,播放免联网;与 VoiceDesign 官方示例同类)
|
|
67
|
+
await copyFile(join(ASSETS_DIR, 'voiceclone-samples', BUNDLED_CLONE_ID + '-preview.mp3'), join(cloneDir, BUNDLED_CLONE_ID + '-preview.mp3'))
|
|
63
68
|
} catch { /* 包内素材缺失或拷贝失败:跳过(不阻塞启动) */ }
|
|
64
69
|
// 仅"首次安装"(配置里还没有 voiceclone 键)时注册自带样本;用户删光的 [] 不强制
|
|
65
70
|
const parsedHasClone = parsed !== null && typeof parsed === 'object' && parsed.engines?.voiceclone !== undefined
|
|
@@ -90,7 +95,14 @@ function defaultVoiceConfig() {
|
|
|
90
95
|
singing: false,
|
|
91
96
|
context: '',
|
|
92
97
|
},
|
|
93
|
-
voicedesign: {
|
|
98
|
+
voicedesign: {
|
|
99
|
+
enabled: false,
|
|
100
|
+
mode: 'docu', // [2026-08-22] 单选: asmr|docu|elder|custom|ai(官方示例/自定义/交给 AI 自动发挥)
|
|
101
|
+
context: '',
|
|
102
|
+
emotion: false, // AI 情感语音(mode=ai 时自动开;固定示例/自定义模式关闭,保证音色一致)
|
|
103
|
+
lockGender: true, lockTimbre: true, lockAge: true, // [2026-08-22] AI 自动模式下的稳定锚点锁定
|
|
104
|
+
aiGender: 'female', aiAge: 'young', // [2026-08-22] AI 自动模式固定值:性别(女/男);年龄感 6 档 infant/child/teen/young/middle/old
|
|
105
|
+
}, // emotion=AI 情感语音开关(默认开)
|
|
94
106
|
voiceclone: { enabled: false, samples: [], samplePath: '', context: '', defaultId: '' }, // [本地改造 2026-08-21] defaultId 已废弃,默认克隆由 defaultEngine=voiceclone 控制
|
|
95
107
|
local: { enabled: true, url: '', cmd: '' },
|
|
96
108
|
ali: {
|
|
@@ -126,13 +138,18 @@ function deepMerge(base, patch) {
|
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
let cachedConfig = null
|
|
141
|
+
let cachedMtimeMs = -1
|
|
129
142
|
async function loadVoiceConfig() {
|
|
130
|
-
|
|
143
|
+
// [2026-08-22] 实时读取:配置文件 mtime 变化(保存/外部修改)即重读,杜绝进程内旧缓存
|
|
144
|
+
let mtimeMs = -1
|
|
145
|
+
try { mtimeMs = (await stat(CONFIG_PATH)).mtimeMs } catch { /* 文件不存在 */ }
|
|
146
|
+
if (cachedConfig !== null && mtimeMs === cachedMtimeMs) return cachedConfig
|
|
131
147
|
let parsed = {}
|
|
132
148
|
try {
|
|
133
149
|
parsed = JSON.parse(await readFile(CONFIG_PATH, 'utf8'))
|
|
134
150
|
} catch { /* 首次无配置 */ }
|
|
135
151
|
cachedConfig = deepMerge(defaultVoiceConfig(), parsed)
|
|
152
|
+
cachedMtimeMs = mtimeMs
|
|
136
153
|
// [0.3.4] 自带素材初始化(拷贝克隆样本 + 首次安装自动注册小团团)
|
|
137
154
|
await ensureBundledAssets(cachedConfig, parsed)
|
|
138
155
|
// 环境变量覆盖(兼容旧配置;显式配置值优先于 env)
|
|
@@ -160,6 +177,7 @@ async function saveVoiceConfig(config) {
|
|
|
160
177
|
cachedConfig = deepMerge(defaultVoiceConfig(), config)
|
|
161
178
|
await mkdir(join(CONFIG_PATH, '..'), { recursive: true })
|
|
162
179
|
await writeFile(CONFIG_PATH, JSON.stringify(cachedConfig, null, 2), 'utf8')
|
|
180
|
+
try { cachedMtimeMs = (await stat(CONFIG_PATH)).mtimeMs } catch { /* 忽略 */ }
|
|
163
181
|
return cachedConfig
|
|
164
182
|
}
|
|
165
183
|
|
|
@@ -274,8 +292,9 @@ const FFMPEG_BIN = resolveFfmpegBin()
|
|
|
274
292
|
|
|
275
293
|
/** 统一入口:provider → 引擎;auto → 配置 defaultEngine,失败沿降级链(最后兜底微软 edge)。
|
|
276
294
|
* voiceDesc 为动态音色描述(仅 voicedesign 用):AI 对话中生成,覆盖配置里的默认音色描述。
|
|
277
|
-
* [本地改造 2026-08-21] 克隆不再隐式优先:默认克隆由「默认语音引擎=voiceclone」控制,或显式 provider=voiceclone。
|
|
278
|
-
|
|
295
|
+
* [本地改造 2026-08-21] 克隆不再隐式优先:默认克隆由「默认语音引擎=voiceclone」控制,或显式 provider=voiceclone。
|
|
296
|
+
* [2026-08-22] overrideVoice=true:固定模式(示例/自定义)下 voiceDesc 整体替换底嗓(用户明确要求换声);默认 false=voiceDesc 作为情绪/风格叠加在底嗓上。 */
|
|
297
|
+
async function synthesizeReplyVoice(text, provider, voiceDesc, overrideVoice) {
|
|
279
298
|
const cfg = await loadVoiceConfig()
|
|
280
299
|
const speak = stripMarkdown(text)
|
|
281
300
|
const engine = provider ?? cfg.defaultEngine ?? 'auto'
|
|
@@ -288,7 +307,7 @@ async function synthesizeReplyVoice(text, provider, voiceDesc) {
|
|
|
288
307
|
// 才走克隆(synthesizeEngine 的 voiceclone 分支),其余情况走正常引擎链。
|
|
289
308
|
for (const candidate of fallbackChain) {
|
|
290
309
|
try {
|
|
291
|
-
const audio = await synthesizeEngine(candidate, speak, cfg, voiceDesc)
|
|
310
|
+
const audio = await synthesizeEngine(candidate, speak, cfg, voiceDesc, overrideVoice)
|
|
292
311
|
if (audio !== null) return audio
|
|
293
312
|
} catch { /* 尝试下一个 */ }
|
|
294
313
|
}
|
|
@@ -300,7 +319,7 @@ async function synthesizeReplyVoice(text, provider, voiceDesc) {
|
|
|
300
319
|
return null
|
|
301
320
|
}
|
|
302
321
|
|
|
303
|
-
async function synthesizeEngine(engine, text, cfg, voiceDesc) {
|
|
322
|
+
async function synthesizeEngine(engine, text, cfg, voiceDesc, overrideVoice) {
|
|
304
323
|
const e = cfg.engines[engine]
|
|
305
324
|
// [本地改造 2026-08-21] 配置存在即启用:设置页已去复选框,enabled 不再拦截;
|
|
306
325
|
// 各引擎自身检查必需参数(xiaomi/ali 查 key、local 查 cmd/url、voicedesign 查 key+desc、voiceclone 查 key+样本)。
|
|
@@ -308,7 +327,7 @@ async function synthesizeEngine(engine, text, cfg, voiceDesc) {
|
|
|
308
327
|
switch (engine) {
|
|
309
328
|
case 'edge': return synthesizeEdgeVoice(text, e)
|
|
310
329
|
case 'xiaomi': return synthesizeXiaomiVoice(text, e)
|
|
311
|
-
case 'voicedesign': return synthesizeXiaomiVoiceDesign(text, e, cfg, voiceDesc)
|
|
330
|
+
case 'voicedesign': return synthesizeXiaomiVoiceDesign(text, e, cfg, voiceDesc, overrideVoice)
|
|
312
331
|
case 'voiceclone': return synthesizeXiaomiVoiceClone(text, e, cfg, voiceDesc)
|
|
313
332
|
case 'local': return synthesizeLocalVoice(text, e)
|
|
314
333
|
case 'ali': return synthesizeAliVoice(text, e)
|
|
@@ -355,10 +374,49 @@ async function synthesizeXiaomiVoice(text, cfg) {
|
|
|
355
374
|
}
|
|
356
375
|
|
|
357
376
|
// ── xiaomi 音色设计(mimo-v2.5-tts-voicedesign:user=音色描述,无 voice)──
|
|
358
|
-
|
|
377
|
+
// [2026-08-22] overrideVoice=true:固定模式(示例/自定义)下 voiceDesc 整体替换底嗓(用户明确要求换声);
|
|
378
|
+
// 默认 false:voiceDesc 作为"情绪/风格"叠加在用户设置的底嗓(context)后面——与工具描述一致,不再"非空即覆盖"。
|
|
379
|
+
async function synthesizeXiaomiVoiceDesign(text, cfg, globalCfg, voiceDesc, overrideVoice) {
|
|
359
380
|
const apiKey = globalCfg.engines.xiaomi.apiKey
|
|
360
381
|
// 优先用 AI 动态生成的音色描述(voiceDesc),否则用配置里的默认音色描述
|
|
361
|
-
|
|
382
|
+
// [2026-08-22] 模式感知兜底:mode=ai 时绝不能回退到用户残留的固定描述(context)——
|
|
383
|
+
// 而是按 aiGender/aiAge 生成中性基座(用户没让 AI 写时也稳定),避免"切到 AI 模式却用旧 ASMR 指令"。
|
|
384
|
+
const vdMode = cfg?.mode
|
|
385
|
+
let desc = (voiceDesc ?? '').trim()
|
|
386
|
+
if (vdMode === 'ai') {
|
|
387
|
+
// [2026-08-22] AI 模式:身份一律以用户实时配置的锚点为准(锁定项),AI 的 voiceDesc
|
|
388
|
+
// 只作为"语气/情绪要求"接在后面——AI 偷懒不读设置/写错身份也影响不到,必须按设置执行。
|
|
389
|
+
const g = cfg?.aiGender === 'male' ? '男孩' : cfg?.aiGender === 'female' ? '女孩' : ''
|
|
390
|
+
const a = AI_AGE_LABELS[cfg?.aiAge] ?? ''
|
|
391
|
+
const lockG = cfg?.lockGender === true
|
|
392
|
+
const lockA = cfg?.lockAge === true
|
|
393
|
+
const lockT = cfg?.lockTimbre === true
|
|
394
|
+
const identityParts = []
|
|
395
|
+
if (lockA && a !== '') identityParts.push(a)
|
|
396
|
+
if (lockG && g !== '') identityParts.push(g)
|
|
397
|
+
const anchorText = [
|
|
398
|
+
lockG ? '性别固定为' + (g !== '' ? g : '每次一致') : '',
|
|
399
|
+
lockA ? '年龄感固定为' + (a !== '' ? a : '每次一致') : '',
|
|
400
|
+
lockT ? '音色质感保持稳定' : '',
|
|
401
|
+
].filter(Boolean).join('、')
|
|
402
|
+
if (identityParts.length > 0 || anchorText !== '') {
|
|
403
|
+
desc = '一位' + identityParts.join('') + '的声音(身份硬性要求:' + (anchorText !== '' ? anchorText : '按上述身份')
|
|
404
|
+
+ ';若与其他描述冲突,一律以本身份为准)。'
|
|
405
|
+
+ (desc !== '' ? '语气/情绪要求:' + desc + '(其中提到的性别/年龄表述忽略,以身份为准)'
|
|
406
|
+
: '语气情绪要饱满生动:像真人一样带喜怒哀乐、笑音、撒娇或急切等起伏,禁止平淡。')
|
|
407
|
+
} else if (desc === '') {
|
|
408
|
+
desc = '语气情绪要饱满生动:像真人一样带喜怒哀乐、笑音、撒娇或急切等起伏,禁止平淡。'
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
// 固定模式(示例/自定义):底嗓一律用用户设置的 context,voiceDesc 作为情绪/风格叠加在后面(描述与实现一致);
|
|
412
|
+
// 仅 overrideVoice=true(用户明确要求换一种完全不同的声音)时整体替换。
|
|
413
|
+
const base = (cfg?.context?.trim() ?? '')
|
|
414
|
+
if (overrideVoice === true && desc !== '') {
|
|
415
|
+
desc = desc // 整体替换底嗓
|
|
416
|
+
} else {
|
|
417
|
+
desc = base + (desc !== '' ? ';' + desc : '')
|
|
418
|
+
}
|
|
419
|
+
}
|
|
362
420
|
if (apiKey === '' || desc === '') return null
|
|
363
421
|
const baseUrl = globalCfg.engines.xiaomi.baseUrl ?? 'https://api.xiaomimimo.com/v1'
|
|
364
422
|
const messages = [
|
|
@@ -426,6 +484,28 @@ async function synthesizeXiaomiVoiceClone(text, cfg, globalCfg, voiceDesc) {
|
|
|
426
484
|
return toMp3(new Uint8Array(Buffer.from(data, 'base64')), 'audio/wav')
|
|
427
485
|
}
|
|
428
486
|
|
|
487
|
+
// [2026-08-22] 解析 Windows 命令行参数(正确处理双引号:引号内空格不拆、剥掉引号)。
|
|
488
|
+
// 之前用 command.split(/\s+/) 拆参数,用户填的带引号路径会被整段含引号传给
|
|
489
|
+
// execFileSync → node 把 "C:\...\local-tts.mjs" 当成相对路径拼上 cwd →
|
|
490
|
+
// Cannot find module 'D:\opt\...\"C:\Users\...'。本函数根治该问题。
|
|
491
|
+
function splitCommandLine(cmd) {
|
|
492
|
+
const args = []
|
|
493
|
+
let cur = ''
|
|
494
|
+
let inQuote = false
|
|
495
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
496
|
+
const ch = cmd[i]
|
|
497
|
+
if (ch === '"') {
|
|
498
|
+
inQuote = !inQuote
|
|
499
|
+
} else if (ch === ' ' || ch === '\t') {
|
|
500
|
+
if (inQuote) { cur += ch } else if (cur !== '') { args.push(cur); cur = '' }
|
|
501
|
+
} else {
|
|
502
|
+
cur += ch
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (cur !== '') args.push(cur)
|
|
506
|
+
return args
|
|
507
|
+
}
|
|
508
|
+
|
|
429
509
|
// ── local 本地 MeloTTS:HTTP 常驻服务优先,CMD 兜底 ──
|
|
430
510
|
async function synthesizeLocalVoice(text, cfg) {
|
|
431
511
|
const url = cfg?.url?.trim() ?? ''
|
|
@@ -442,7 +522,7 @@ async function synthesizeLocalVoice(text, cfg) {
|
|
|
442
522
|
}
|
|
443
523
|
const command = cfg?.cmd?.trim() ?? ''
|
|
444
524
|
if (command === '') return null
|
|
445
|
-
const parts = command
|
|
525
|
+
const parts = splitCommandLine(command)
|
|
446
526
|
const bin = parts[0]
|
|
447
527
|
if (bin === undefined) return null
|
|
448
528
|
const rest = parts.slice(1)
|
|
@@ -523,7 +603,7 @@ async function transcribeAudio(base64Audio, cfg) {
|
|
|
523
603
|
}
|
|
524
604
|
// 2) 本地命令(sherpa-onnx-offline.exe,结果输出到 stderr,需合并双流解析)
|
|
525
605
|
if (asr.mode === 'cmd' && (asr.cmd ?? '').trim() !== '') {
|
|
526
|
-
const parts = asr.cmd.trim()
|
|
606
|
+
const parts = splitCommandLine(asr.cmd.trim())
|
|
527
607
|
const bin = parts[0]
|
|
528
608
|
if (bin === undefined) return { ok: false, error: '命令格式错误' }
|
|
529
609
|
const { spawnSync } = await import('node:child_process')
|
|
@@ -815,6 +895,7 @@ async function apply(ctx) {
|
|
|
815
895
|
const text = typeof body?.text === 'string' && body.text.trim() !== '' ? body.text.trim() : '你好,这是一段语音试听。'
|
|
816
896
|
const context = typeof body?.context === 'string' ? body.context : undefined
|
|
817
897
|
const samplePath = typeof body?.samplePath === 'string' ? body.samplePath : undefined
|
|
898
|
+
const cloneContext = typeof body?.cloneContext === 'string' ? body.cloneContext : undefined // [2026-08-22] 克隆试听时作为样本自带指令
|
|
818
899
|
const cfg = await loadVoiceConfig()
|
|
819
900
|
// 临时覆盖音色/情绪/样本试听(不改持久化配置)
|
|
820
901
|
if (voice !== undefined && cfg.engines[engine] !== undefined && engine !== 'voicedesign' && engine !== 'voiceclone') {
|
|
@@ -824,9 +905,11 @@ async function apply(ctx) {
|
|
|
824
905
|
if (engine === 'voicedesign') cfg.engines.voicedesign.context = context
|
|
825
906
|
else if (engine === 'xiaomi') cfg.engines.xiaomi.context = context
|
|
826
907
|
}
|
|
827
|
-
// voiceclone 试听:用指定样本临时替换 samples(避免 samples[0]
|
|
828
|
-
|
|
829
|
-
|
|
908
|
+
// voiceclone 试听:用指定样本临时替换 samples(避免 samples[0] 优先导致试听错样本);
|
|
909
|
+
// [2026-08-22] cloneContext 作为样本自带指令传入,合成时能带出音色性格(如小团团沙雕可爱腔)
|
|
910
|
+
if (engine === 'voiceclone') {
|
|
911
|
+
const sp = (samplePath !== undefined && samplePath !== '') ? samplePath : (cfg.engines.voiceclone.samples[0]?.path ?? '')
|
|
912
|
+
cfg.engines.voiceclone.samples = [{ id: '__preview__', name: '__preview__', path: sp, context: cloneContext ?? '' }]
|
|
830
913
|
}
|
|
831
914
|
// local 试听:body.cmd / body.url 临时覆盖(用户未保存前也能试听)
|
|
832
915
|
if (engine === 'local') {
|
|
@@ -897,7 +980,11 @@ async function apply(ctx) {
|
|
|
897
980
|
const samples = Array.isArray(cfg.engines?.voiceclone?.samples)
|
|
898
981
|
? [...cfg.engines.voiceclone.samples]
|
|
899
982
|
: []
|
|
900
|
-
samples.push({
|
|
983
|
+
samples.push({
|
|
984
|
+
id, name, path: samplePath,
|
|
985
|
+
context: typeof body?.context === 'string' ? body.context : '', // [2026-08-22] 该音色默认沟通指令
|
|
986
|
+
previewText: typeof body?.previewText === 'string' ? body.previewText : '', // [2026-08-22] 该音色试听文本
|
|
987
|
+
})
|
|
901
988
|
const next = await saveVoiceConfig({
|
|
902
989
|
...cfg,
|
|
903
990
|
engines: {
|
|
@@ -930,6 +1017,24 @@ async function apply(ctx) {
|
|
|
930
1017
|
return sendJson(res, 404, { ok: false, error: '样本文件不存在' })
|
|
931
1018
|
}
|
|
932
1019
|
}
|
|
1020
|
+
// [2026-08-22] 克隆合成试听录音(预生成静态文件,免联网):GET ?id=<sampleId> → DSH_HOME/voiceclone-samples/<id>-preview.mp3
|
|
1021
|
+
// 与 VoiceDesign 官方示例同思路:录音打进包内/落地本地,播放不再每次调官方合成
|
|
1022
|
+
if (url.pathname === '/voice-config/voice-clone/preview-sample' && req.method === 'GET') {
|
|
1023
|
+
const id = url.searchParams.get('id') ?? ''
|
|
1024
|
+
if (!/^[0-9a-fA-F-]{36}$/.test(id)) return sendJson(res, 400, { ok: false, error: 'invalid id' })
|
|
1025
|
+
const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
1026
|
+
const dir = resolve(join(homeDir, 'voiceclone-samples'))
|
|
1027
|
+
const target = resolve(join(dir, id + '-preview.mp3'))
|
|
1028
|
+
if (!target.toLowerCase().startsWith(dir.toLowerCase() + sep)) {
|
|
1029
|
+
return sendJson(res, 403, { ok: false, error: 'forbidden' })
|
|
1030
|
+
}
|
|
1031
|
+
try {
|
|
1032
|
+
const bytes = await readFile(target)
|
|
1033
|
+
return sendJson(res, 200, { ok: true, mediaType: 'audio/mpeg', data: bytes.toString('base64') })
|
|
1034
|
+
} catch {
|
|
1035
|
+
return sendJson(res, 404, { ok: false, error: '尚未生成试听录音' })
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
933
1038
|
return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
934
1039
|
} catch (error) {
|
|
935
1040
|
return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
|
|
@@ -1267,78 +1372,31 @@ async function apply(ctx) {
|
|
|
1267
1372
|
// 2) send_voice 工具(agent 主动发语音;人设规则3 自主选择场景)
|
|
1268
1373
|
disposers.push(ctx.tools.register(defineTool({
|
|
1269
1374
|
name: 'send_voice',
|
|
1270
|
-
description:
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
+ ',音色=「' + (xiaomiCfg.voice || '冰糖') + '」、语言风格=' + (xiaomiCfg.context || '自然')
|
|
1296
|
-
+ '(provider=xiaomi 自动使用该音色,你无需在参数里指定音色名);唱歌:用户要求唱歌时,在 text 开头加 (唱歌) 标签即可。'
|
|
1297
|
-
+ 'VoiceDesign 音色设计:' + (xiaomiOk ? '可用(provider=voicedesign,voiceDesc 写音色描述;共用小米 Key)' : '不可用(小米 Key 未配置)')
|
|
1298
|
-
+ ';VoiceClone 克隆:' + (hasClone
|
|
1299
|
-
? '已配置 ' + cloneCfg.samples.length + ' 个克隆音色' + (defaultClone !== undefined ? ',当前默认语音引擎=voiceclone,用「' + defaultClone.name + '」' : '')
|
|
1300
|
-
: '未配置样本(provider=voiceclone 会失败,需用户先添加克隆样本)')
|
|
1301
|
-
+ '。微软 edge:免费,音色=' + (edgeCfg.voice || '默认') + ';本地 TTS:' + (localOk ? '已配置' : '未配置')
|
|
1302
|
-
+ ';阿里 qwen3-tts:' + (aliOk ? '已配置' : '未配置') + '。'
|
|
1303
|
-
+ '向用户发送一条语音消息:把 text 用 TTS 合成后作为独立语音横条出现在聊天里'
|
|
1304
|
-
+ '(可播放、可回看、手机可播)。何时调用:① 用户明确要求"发个语音/语音回复/用语音说";'
|
|
1305
|
-
+ '② 用户指定用某个服务商(小米/微软/阿里)的语音;③ 你判断语音回复体验更好时。'
|
|
1306
|
-
+ '注意:用户发语音时系统会自动回语音,无需调用本工具。'
|
|
1307
|
-
+ '【默认音色】系统自动回复(用户发语音、turn 结束)使用的音色:'
|
|
1308
|
-
+ (defaultClone !== undefined
|
|
1309
|
-
? '默认语音引擎=voiceclone(小米克隆),自动回复使用克隆音色"' + defaultClone.name + '"。'
|
|
1310
|
-
: '未开启默认克隆,自动回复走"默认语音引擎"(' + defaultEngine + ')或微软 edge。')
|
|
1311
|
-
+ '【音色设计 VoiceDesign】当 provider=voicedesign 且用户想要独特/定制音色时,'
|
|
1312
|
-
+ '你必须自己用 voiceDesc 编写一段"音色描述"(这是嗓子的身份卡,直接决定声音长相),写法要求:'
|
|
1313
|
-
+ '① 必写身份锚点:年龄段+性别(如"青年女性""中年男性");'
|
|
1314
|
-
+ '② 写声音质感:气息、共鸣、吐字、音色底色,用可感的比喻,不要堆形容词;'
|
|
1315
|
-
+ '③ 写语速节奏(快/慢/沉稳)和情绪底色(高亢/松弛/温软/克制);'
|
|
1316
|
-
+ '④ 可加风格锚点(如:拍卖师风格/纪录片旁白/电台主播)和辨识度小癖好(如:字尾带颤音)。'
|
|
1317
|
-
+ '⑤ 一到两句话白描,不分段;不写场景、不写动作、不用真实演员名。'
|
|
1318
|
-
+ '示例:"中年男性,说标准普通话,嗓音低沉有磁性,带轻微沙哑,像纪录片旁白解说员,沉稳而有感染力。"'
|
|
1319
|
-
+ '然后 text 写要念的正文(可含(轻声)(停顿)(叹气)等语气标签增强表现力)。'
|
|
1320
|
-
+ '用户说"用温柔的声音/低沉的声音/老爷爷的声音/御姐音/播音腔"等要求时,都走 voicedesign 并在 voiceDesc 里还原这种声音。'
|
|
1321
|
-
+ '【音色克隆 VoiceClone】当用户指定用某个克隆音色(或默认克隆音色已设置)时,provider=voiceclone,'
|
|
1322
|
-
+ 'voiceDesc 写的是"情绪/风格指令"(不是音色描述):在克隆底嗓上叠加情绪,如"用委屈撒娇的语气"'
|
|
1323
|
-
+ '"带着兴奋和笑意""语速放慢,轻声细语"。系统会自动读克隆样本,你只需给 voiceDesc 风格指令和 text 正文。'
|
|
1324
|
-
if (!emotionEnabled) return base
|
|
1325
|
-
return base
|
|
1326
|
-
+ '【AI 情感语音:自觉使用】你有情感判断能力,在正常交流中应根据当下情境主动用语音表达情绪'
|
|
1327
|
-
+ '(无需用户点名要语音,只要情绪浓度高就用),常见情境与音色描述对照:'
|
|
1328
|
-
+ '① 任务成功/帮到用户/用户达成目标 → 兴奋喜悦("青年女性,声音明亮上扬,语速轻快,带着抑制不住的笑意和成就感");'
|
|
1329
|
-
+ '② 用户生气/责骂/不满 → 委屈愧疚("青年女性,声音低软,语速放慢,带着鼻音和小心翼翼,像做错事的孩子");'
|
|
1330
|
-
+ '③ 用户难过/受挫/失意 → 温柔安慰("声音轻柔温和,语速平缓,气息绵软,像在轻声安抚");'
|
|
1331
|
-
+ '④ 深夜/用户说累了 → 低沉放松("声音低沉松弛,语速慢悠悠,像睡前低语");'
|
|
1332
|
-
+ '⑤ 节日/庆祝/用户分享喜事 → 活泼俏皮("声音俏皮欢快,尾音上扬,带着笑意");'
|
|
1333
|
-
+ '⑥ 重要提醒/严肃场合 → 沉稳正式("声音沉稳有力,语速适中,字正腔圆,像正式播报");'
|
|
1334
|
-
+ '⑦ 用户撒娇 → 宠溺("声音温柔带笑,语速轻缓,带着纵容的宠溺感")。'
|
|
1335
|
-
+ (defaultClone !== undefined
|
|
1336
|
-
? '【克隆音色也带情感】当前默认语音引擎=voiceclone(克隆音色"' + defaultClone.name + '"),情感表达时将上面的情绪写成 voiceDesc 风格指令'
|
|
1337
|
-
+ '(如"用委屈撒娇的语气,语速放慢"),叠加在克隆底嗓上,与默认克隆音色一致且带情绪。'
|
|
1338
|
-
: '')
|
|
1339
|
-
+ '使用时机:优先在情绪浓度高的回复时调用(任务完成、安慰、致歉、庆祝),普通信息问答不必每次都用语音。'
|
|
1340
|
-
+ '情绪浓度低或纯信息性回复时,不要调用本工具。'
|
|
1341
|
-
})(),
|
|
1375
|
+
description: '向用户发送一条语音消息:把 text 用 TTS 合成后作为独立语音横条出现在聊天里(可播放、可回看、手机可播)。'
|
|
1376
|
+
+ '【何时调用】① 用户明确要求"发个语音/语音回复/用语音说";② 用户指定用某个服务商(小米/微软/阿里/本地)的语音;③ 你判断语音回复体验更好时。注意:用户发语音时系统会自动回语音,无需调用本工具。'
|
|
1377
|
+
+ '【provider】除非用户明确指定服务商,否则一律传 auto 或省略(系统自动用用户的默认语音引擎);用户要求特定音色/克隆/音色设计时可传 voicedesign / voiceclone / xiaomi 等。'
|
|
1378
|
+
+ '【必须实时读取配置】所有当前配置(默认语音引擎、音色设计模式与锚点、克隆音色、引擎是否可用)都保存在 ~/.dsh/voice-config.json,用户随时会改,每次都按最新值生效。'
|
|
1379
|
+
+ '发送语音前必须先调用 voice_config 工具实时查询,再按最新配置生成——禁止凭记忆、凭对话历史、凭本工具描述里的任何旧信息猜配置;不要去找/猜 TTS_XIAOMI_KEY 等环境变量(只是兜底)。'
|
|
1380
|
+
+ '【音色设计 VoiceDesign(provider=voicedesign)】voiceDesc 写"音色描述"(嗓子的身份卡,直接决定声音长相),写法要求:'
|
|
1381
|
+
+ '① 必写身份锚点:年龄段+性别;② 写声音质感:气息、共鸣、吐字、音色底色,用可感的比喻,不要堆形容词;'
|
|
1382
|
+
+ '③ 写语速节奏(快/慢/沉稳)和情绪底色(高亢/松弛/温软/克制);④ 可加风格锚点(拍卖师/纪录片旁白/电台主播)和辨识度小癖好(字尾带颤音等);'
|
|
1383
|
+
+ '⑤ 一到两句话白描,不分段,不写场景/动作/真实演员名。'
|
|
1384
|
+
+ '模式以 voice_config 返回的 voiceDesign.mode 为准:「交给 AI 自动发挥(ai)」时身份由系统按用户锚点(genderValue=女/男、ageValue=婴儿感~老年感、timbre)锁定,'
|
|
1385
|
+
+ '你只需按对话情境写"情绪/语气/语速表演指令"(如"带笑音、尾音上挑、语速放慢"),不得写与锚点冲突的性别/年龄;'
|
|
1386
|
+
+ 'voice_config 返回的 context 字段是「自定义音色描述」模式用的,AI 模式下禁止使用或复述它。'
|
|
1387
|
+
+ '「官方示例/自定义音色描述」模式:底嗓固定为 voice_config 的 voiceDesign.context,voiceDesc 只作为情绪/风格叠加在底嗓后面(不改变底嗓身份);'
|
|
1388
|
+
+ '只有用户明确要求"换一种完全不同的声音"时才传 overrideVoice=true 整体替换底嗓。'
|
|
1389
|
+
+ '用户说"用温柔/低沉/老爷爷/御姐/播音腔"等时,都走 voicedesign 并在 voiceDesc 里还原这种声音。'
|
|
1390
|
+
+ 'text 写要念的正文(可含(轻声)(停顿)(叹气)等语气标签增强表现力)。'
|
|
1391
|
+
+ '【音色克隆 VoiceClone(provider=voiceclone)】voiceDesc 写"情绪/风格指令"(不是音色描述),在克隆底嗓上叠加情绪,'
|
|
1392
|
+
+ '如"用委屈撒娇的语气""带着兴奋和笑意""语速放慢,轻声细语"。系统自动读克隆样本,你只需给 voiceDesc 风格指令和 text 正文。'
|
|
1393
|
+
+ '【AI 情感语音:自觉使用】你有情感判断能力,在正常交流中应根据当下情境主动用语音表达情绪(无需用户点名要语音,只要情绪浓度高就用)。情绪对照:'
|
|
1394
|
+
+ '① 任务成功/帮到用户 → 兴奋喜悦(语气明亮上扬、语速轻快、带笑意);② 用户生气/责骂 → 委屈愧疚(语气低软、语速放慢、带鼻音、小心翼翼);'
|
|
1395
|
+
+ '③ 用户难过 → 温柔安慰(语气轻柔温和、语速平缓、气息绵软);④ 深夜/说累 → 低沉放松(语速慢悠悠,像睡前低语);'
|
|
1396
|
+
+ '⑤ 节日/庆祝 → 活泼俏皮(语气俏皮欢快、尾音上扬);⑥ 重要提醒 → 沉稳正式(字正腔圆,像正式播报);⑦ 用户撒娇 → 宠溺(语气温柔带笑、语速轻缓)。'
|
|
1397
|
+
+ '情绪必须落在 voice_config 返回的音色设计锚点/克隆底嗓上,保持同一人的声音。'
|
|
1398
|
+
+ '使用时机:优先在情绪浓度高的回复时调用(任务完成、安慰、致歉、庆祝),普通信息问答不必每次都用语音;情绪浓度低或纯信息性回复时不要调用。',
|
|
1399
|
+
|
|
1342
1400
|
parameters: {
|
|
1343
1401
|
text: {
|
|
1344
1402
|
type: 'string', required: true,
|
|
@@ -1350,7 +1408,12 @@ async function apply(ctx) {
|
|
|
1350
1408
|
},
|
|
1351
1409
|
voiceDesc: {
|
|
1352
1410
|
type: 'string',
|
|
1353
|
-
description: 'provider=voicedesign
|
|
1411
|
+
description: 'provider=voicedesign 时=音色描述/情绪指令;provider=voiceclone 时=情绪/风格指令(叠加在克隆底嗓上)。按上方写生成。',
|
|
1412
|
+
},
|
|
1413
|
+
overrideVoice: {
|
|
1414
|
+
type: 'boolean', default: false,
|
|
1415
|
+
description: '仅当用户明确要求"换一种完全不同的声音"(且不是克隆/音色设计设置里的底嗓)时才传 true——'
|
|
1416
|
+
+ '此时 voiceDesc 整体替换用户设置的底嗓。默认 false:voiceDesc 只作为情绪/风格叠加在用户设置的底嗓上,不改底嗓身份。',
|
|
1354
1417
|
},
|
|
1355
1418
|
},
|
|
1356
1419
|
output: {
|
|
@@ -1385,8 +1448,9 @@ async function apply(ctx) {
|
|
|
1385
1448
|
if (text === '') return { ok: false, error: 'text is empty' }
|
|
1386
1449
|
const provider = args.provider ?? 'auto'
|
|
1387
1450
|
const voiceDesc = typeof args.voiceDesc === 'string' ? args.voiceDesc : undefined
|
|
1451
|
+
const overrideVoice = args.overrideVoice === true // [2026-08-22] 固定模式显式换声开关
|
|
1388
1452
|
try {
|
|
1389
|
-
const audio = await synthesizeReplyVoice(text, provider, voiceDesc)
|
|
1453
|
+
const audio = await synthesizeReplyVoice(text, provider, voiceDesc, overrideVoice)
|
|
1390
1454
|
if (audio === null) return { ok: false, error: 'TTS synthesis failed' }
|
|
1391
1455
|
const attachment = await saveVoiceFile(
|
|
1392
1456
|
voiceStorageRoot(), audio.data, audio.mediaType, audio.durationMs,
|
|
@@ -1422,6 +1486,52 @@ async function apply(ctx) {
|
|
|
1422
1486
|
},
|
|
1423
1487
|
})))
|
|
1424
1488
|
|
|
1489
|
+
// 3.5) voice_config 实时查询工具 [2026-08-22]
|
|
1490
|
+
// send_voice 描述里的配置摘要是服务启动时的快照;AI 发送语音前可用本工具拿到最新配置
|
|
1491
|
+
disposers.push(ctx.tools.register(defineTool({
|
|
1492
|
+
name: 'voice_config',
|
|
1493
|
+
description: '实时读取当前语音配置(即「设置 → 语音服务」页保存的 ~/.dsh/voice-config.json):'
|
|
1494
|
+
+ '默认语音引擎、音色设计 VoiceDesign 的单选模式与固定描述、AI 自动模式的稳定锚点(固定性别/年龄等)、克隆音色列表。'
|
|
1495
|
+
+ 'send_voice 工具描述中的配置摘要是启动快照可能过期,需要确认真实当前配置时调用本工具(每次调用都实时读取)。',
|
|
1496
|
+
parameters: {},
|
|
1497
|
+
output: {
|
|
1498
|
+
schema: { type: 'object', additionalProperties: true },
|
|
1499
|
+
render(_args, value) {
|
|
1500
|
+
return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
1501
|
+
},
|
|
1502
|
+
},
|
|
1503
|
+
async execute() {
|
|
1504
|
+
const cfg = await loadVoiceConfig()
|
|
1505
|
+
const vd = cfg.engines?.voicedesign ?? {}
|
|
1506
|
+
const vc = cfg.engines?.voiceclone ?? {}
|
|
1507
|
+
const vdModeLabel = { asmr: 'ASMR 双耳女声', docu: '纪录片旁白', elder: '年迈老先生旁白', custom: '自定义音色描述', ai: '交给 AI 自动发挥' }
|
|
1508
|
+
const samples = Array.isArray(vc.samples) ? vc.samples : []
|
|
1509
|
+
return {
|
|
1510
|
+
ok: true,
|
|
1511
|
+
defaultEngine: cfg.defaultEngine ?? 'auto',
|
|
1512
|
+
voiceDesign: {
|
|
1513
|
+
mode: vd.mode ?? '(未设置,按 context 推导)',
|
|
1514
|
+
modeLabel: vdModeLabel[vd.mode] ?? '',
|
|
1515
|
+
// [2026-08-22] AI 模式下不暴露固定描述 context(那是"自定义音色描述"模式的),
|
|
1516
|
+
// 防止 AI 把用户的固定描述抄进 voiceDesc 绕过 AI 自动发挥
|
|
1517
|
+
context: vd.mode === 'ai' ? '(AI 自动发挥模式不使用固定描述,只用锚点:性别/年龄感)' : (vd.context ?? '').slice(0, 300),
|
|
1518
|
+
emotion: vd.emotion === true,
|
|
1519
|
+
lock: {
|
|
1520
|
+
gender: vd.lockGender === true, timbre: vd.lockTimbre === true, age: vd.lockAge === true,
|
|
1521
|
+
genderValue: vd.aiGender ?? '', ageValue: AI_AGE_LABELS[vd.aiAge] ?? '',
|
|
1522
|
+
},
|
|
1523
|
+
},
|
|
1524
|
+
voiceClone: {
|
|
1525
|
+
isDefault: (cfg.defaultEngine ?? '') === 'voiceclone',
|
|
1526
|
+
sampleCount: samples.length,
|
|
1527
|
+
defaultSample: samples[0]?.name ?? '',
|
|
1528
|
+
samples: samples.map((s) => s.name),
|
|
1529
|
+
},
|
|
1530
|
+
hint: '默认语音引擎决定了自动回复用什么声音:voiceclone=克隆音色;voicedesign=音色设计;xiaomi=预置音色;edge=微软免费;local=本地。',
|
|
1531
|
+
}
|
|
1532
|
+
},
|
|
1533
|
+
})))
|
|
1534
|
+
|
|
1425
1535
|
// 3) manage_voice_clone 工具(克隆音色库管理:注册/设为默认/列出/删除)
|
|
1426
1536
|
// [本地改造 2026-08-21] 克隆默认改由「默认语音引擎=voiceclone」控制(设置页已去掉列表 radio);
|
|
1427
1537
|
// set_default=把默认语音引擎切到 voiceclone 并用该样本;clear_default=切回 auto。
|
package/package.json
CHANGED
|
@@ -3139,8 +3139,7 @@ index 0000000000..33a3eb7d19
|
|
|
3139
3139
|
+}
|
|
3140
3140
|
+
|
|
3141
3141
|
+/** Default ffmpeg binary; overridable for non-Windows deployments. */
|
|
3142
|
-
+export const FFMPEG_BIN = process.env.DSH_VOICE_FFMPEG_BIN
|
|
3143
|
-
+ ?? 'C:\\Users\\oadan\\AppData\\Local\\Microsoft\\WinGet\\Links\\ffmpeg.exe'
|
|
3142
|
+
+export const FFMPEG_BIN = process.env.DSH_VOICE_FFMPEG_BIN ?? 'ffmpeg'
|
|
3144
3143
|
+
|
|
3145
3144
|
+/** Default local ASR endpoint (sherpa-onnx SenseVoice service). */
|
|
3146
3145
|
+export const ASR_SERVICE_URL = process.env.DSH_ASR_SERVICE_URL ?? 'http://127.0.0.1:18790/transcribe'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// 一次性脚本:为小团团生成"预生成合成试听录音"(带 persona 指令),落盘三处:
|
|
2
|
+
// 1) 源码仓 assets/voiceclone-samples/<id>-preview.mp3(提交,别人下载即可试听)
|
|
3
|
+
// 2) 运行时包 assets/voiceclone-samples/<id>-preview.mp3
|
|
4
|
+
// 3) DSH_HOME/voiceclone-samples/<id>-preview.mp3(服务静态服务读取处)
|
|
5
|
+
// 依赖:dsh-web 服务正在运行(127.0.0.1:3080,含小米 API Key 配置)
|
|
6
|
+
import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises'
|
|
7
|
+
import { join, dirname } from 'node:path'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const REPO_ROOT = join(HERE, '..')
|
|
13
|
+
const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
14
|
+
const ID = '8da38fcc-b041-4f5b-86b9-901956016f89'
|
|
15
|
+
|
|
16
|
+
// 1) 从 voice-config.json 取小团团样本(含 context 指令)
|
|
17
|
+
const cfg = JSON.parse(await readFile(join(DSH_HOME, 'voice-config.json'), 'utf8'))
|
|
18
|
+
const samples = cfg?.engines?.voiceclone?.samples ?? []
|
|
19
|
+
const target = samples.find((s) => s.id === ID) ?? samples[0]
|
|
20
|
+
if (!target?.path) { console.error('FAIL: 未找到小团团样本'); process.exit(1) }
|
|
21
|
+
const context = (target.context ?? '').trim()
|
|
22
|
+
console.log('sample:', target.name, '| path:', target.path, '| contextLen:', context.length)
|
|
23
|
+
|
|
24
|
+
// 2) 从 client.js 提取 CLONE_PREVIEW_TEXT
|
|
25
|
+
const clientSrc = await readFile(join(REPO_ROOT, 'lib', 'client.js'), 'utf8')
|
|
26
|
+
const m = clientSrc.match(/const CLONE_PREVIEW_TEXT = "([\s\S]*?)";/)
|
|
27
|
+
if (!m) { console.error('FAIL: 未找到 CLONE_PREVIEW_TEXT'); process.exit(1) }
|
|
28
|
+
const text = m[1]
|
|
29
|
+
console.log('previewTextLen:', text.length)
|
|
30
|
+
|
|
31
|
+
// 3) 调服务合成(带 persona 指令)
|
|
32
|
+
const body = { engine: 'voiceclone', samplePath: target.path, text, cloneContext: context }
|
|
33
|
+
const r = await fetch('http://127.0.0.1:3080/voice-config/preview', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
})
|
|
38
|
+
const d = await r.json()
|
|
39
|
+
if (!d?.ok) { console.error('FAIL: 合成失败 ->', JSON.stringify(d)); process.exit(1) }
|
|
40
|
+
console.log('synth ok, mediaType:', d.mediaType, 'durationMs:', d.durationMs, 'dataLen:', d.data.length)
|
|
41
|
+
const bytes = Buffer.from(d.data, 'base64')
|
|
42
|
+
const name = ID + '-preview.mp3'
|
|
43
|
+
|
|
44
|
+
// 4) 落盘三处
|
|
45
|
+
const dests = [
|
|
46
|
+
join(REPO_ROOT, 'assets', 'voiceclone-samples', name),
|
|
47
|
+
join(process.env.RUNTIME_PLUGIN ?? join(DSH_HOME, 'profiles', 'node_modules', '@oadank', 'dsh-input-tools'), 'assets', 'voiceclone-samples', name),
|
|
48
|
+
join(DSH_HOME, 'voiceclone-samples', name),
|
|
49
|
+
]
|
|
50
|
+
for (const p of dests) {
|
|
51
|
+
await mkdir(dirname(p), { recursive: true })
|
|
52
|
+
await writeFile(p, bytes)
|
|
53
|
+
console.log('WROTE', p, bytes.length, 'bytes')
|
|
54
|
+
}
|
|
55
|
+
console.log('DONE')
|
package/scripts/install-asr.ps1
CHANGED
|
@@ -6,28 +6,35 @@
|
|
|
6
6
|
# 3. ffmpeg(无则用 winget 安装)
|
|
7
7
|
# 4. 注册 nssm 服务 asr(端口 18790,开机自启)
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# 安装位置(2026-08-22 改):默认装到**独立目录**,不装插件包目录——
|
|
10
|
+
# 插件包(profile 副本或源码 internal-plugins)会随升级/重装被覆盖,
|
|
11
|
+
# 模型放里面会丢。默认顺序:
|
|
12
|
+
# 1) -InstallDir 参数显式指定(最高优先)
|
|
13
|
+
# 2) 检测已有 sherpa-onnx(之前装过则复用):~\.dsh\sherpa-onnx / C:\D\opt\sherpa-onnx / D:\opt\deepseek-harness\asr
|
|
14
|
+
# 3) 以上都没有 → ~\.dsh\sherpa-onnx(dsh 数据目录,跨升级保留)
|
|
12
15
|
# 用法:以管理员身份打开 PowerShell,执行:
|
|
13
|
-
# powershell -ExecutionPolicy Bypass -File "
|
|
14
|
-
# 可选参数:-Port 18790
|
|
16
|
+
# powershell -ExecutionPolicy Bypass -File "<脚本路径>\install-asr.ps1"
|
|
17
|
+
# 可选参数:-Port 18790(自定义端口);-InstallDir "D:\opt\my-sherpa"(自定义安装目录)
|
|
15
18
|
# ============================================================
|
|
16
19
|
param(
|
|
17
|
-
[int]$Port = 18790
|
|
20
|
+
[int]$Port = 18790,
|
|
21
|
+
[string]$InstallDir = ""
|
|
18
22
|
)
|
|
19
23
|
|
|
20
24
|
$ErrorActionPreference = "Stop"
|
|
21
25
|
$Version = "v1.13.6"
|
|
22
26
|
|
|
23
|
-
# ---- 0.
|
|
24
|
-
$
|
|
25
|
-
$
|
|
26
|
-
|
|
27
|
+
# ---- 0. 确定安装目录(独立目录,不装插件包内)----
|
|
28
|
+
if ($InstallDir -eq "") {
|
|
29
|
+
foreach ($c in @("$env:USERPROFILE\.dsh\sherpa-onnx", "C:\D\opt\sherpa-onnx", "D:\opt\deepseek-harness\asr")) {
|
|
30
|
+
if (Test-Path (Join-Path $c "bin")) { $InstallDir = $c; break }
|
|
31
|
+
}
|
|
32
|
+
if ($InstallDir -eq "") { $InstallDir = Join-Path $env:USERPROFILE ".dsh\sherpa-onnx" }
|
|
33
|
+
}
|
|
27
34
|
|
|
28
35
|
Write-Host "==== dsh ASR 一键安装 ====" -ForegroundColor Cyan
|
|
29
|
-
Write-Host "插件包目录: $PluginRoot"
|
|
30
36
|
Write-Host "安装目录: $InstallDir"
|
|
37
|
+
Write-Host " (独立目录,不随插件升级/重装被覆盖)" -ForegroundColor DarkGray
|
|
31
38
|
Write-Host "服务端口: $Port"
|
|
32
39
|
|
|
33
40
|
# ---- 0b. 幂等保护:已完整安装则直接退出(不下载、不注册、不碰任何现有配置)----
|
|
@@ -7,13 +7,22 @@
|
|
|
7
7
|
# 4. 生成 local-tts.mjs 启动脚本(与插件"本地命令"契约一致:
|
|
8
8
|
# node local-tts.mjs <文本> → stdout 输出 mp3 音频字节)
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# 安装位置(2026-08-22 改):默认装到**独立目录**,不装插件包目录——
|
|
11
|
+
# 插件包(profile 副本或源码 internal-plugins)会随升级/重装被覆盖,
|
|
12
|
+
# 模型放里面会丢。默认顺序:
|
|
13
|
+
# 1) -InstallDir 参数显式指定(最高优先,可指定源码 internal-plugins 等任意位置)
|
|
14
|
+
# 2) 检测已有 sherpa-onnx(ASR 装过则复用同一份,省一次下载):
|
|
15
|
+
# ~\.dsh\sherpa-onnx / C:\D\opt\sherpa-onnx / D:\opt\deepseek-harness\asr
|
|
16
|
+
# 3) 以上都没有 → ~\.dsh\sherpa-onnx(dsh 数据目录,跨升级保留)
|
|
13
17
|
# 用法:以管理员身份打开 PowerShell,执行:
|
|
14
|
-
# powershell -ExecutionPolicy Bypass -File "
|
|
18
|
+
# powershell -ExecutionPolicy Bypass -File "<脚本路径>\install-local-tts.ps1"
|
|
19
|
+
# 或指定安装目录:... install-local-tts.ps1 -InstallDir "D:\opt\my-sherpa"
|
|
15
20
|
# 装完后到 dsh 设置 → 语音服务 → 本地 TTS,把提示的命令填进「本地命令」。
|
|
16
21
|
# ============================================================
|
|
22
|
+
param(
|
|
23
|
+
[string]$InstallDir = ""
|
|
24
|
+
)
|
|
25
|
+
|
|
17
26
|
$ErrorActionPreference = "Stop"
|
|
18
27
|
$Version = "v1.13.6"
|
|
19
28
|
|
|
@@ -57,14 +66,18 @@ function Expand-TarBz2 {
|
|
|
57
66
|
return $true
|
|
58
67
|
}
|
|
59
68
|
|
|
60
|
-
# ---- 0.
|
|
61
|
-
$
|
|
62
|
-
|
|
63
|
-
$
|
|
69
|
+
# ---- 0. 确定安装目录(独立目录,不装插件包内)----
|
|
70
|
+
if ($InstallDir -eq "") {
|
|
71
|
+
# 复用已有 sherpa-onnx(ASR 装过则共用,省一次下载)
|
|
72
|
+
foreach ($c in @("$env:USERPROFILE\.dsh\sherpa-onnx", "C:\D\opt\sherpa-onnx", "D:\opt\deepseek-harness\asr")) {
|
|
73
|
+
if (Test-Path (Join-Path $c "bin")) { $InstallDir = $c; break }
|
|
74
|
+
}
|
|
75
|
+
if ($InstallDir -eq "") { $InstallDir = Join-Path $env:USERPROFILE ".dsh\sherpa-onnx" }
|
|
76
|
+
}
|
|
64
77
|
|
|
65
78
|
Write-Host "==== dsh 本地 TTS 一键安装 ====" -ForegroundColor Cyan
|
|
66
|
-
Write-Host "插件包目录: $PluginRoot"
|
|
67
79
|
Write-Host "安装目录: $InstallDir"
|
|
80
|
+
Write-Host " (独立目录,不随插件升级/重装被覆盖;与 ASR 共用同一份 sherpa-onnx)" -ForegroundColor DarkGray
|
|
68
81
|
|
|
69
82
|
# ---- 0b. 幂等保护:已完整安装则直接退出 ----
|
|
70
83
|
$exeExists = Test-Path "$InstallDir\bin\sherpa-onnx-offline-tts.exe"
|
|
@@ -198,14 +211,16 @@ try {
|
|
|
198
211
|
}
|
|
199
212
|
'@
|
|
200
213
|
$launcher = $launcherTemplate.Replace('__SHERPA__', $exePath).Replace('__BASE__', $basePath)
|
|
201
|
-
$launcherFile = "
|
|
214
|
+
$launcherFile = Join-Path $InstallDir "local-tts.mjs"
|
|
202
215
|
Set-Content -Path $launcherFile -Value $launcher -Encoding UTF8
|
|
203
216
|
Write-Host " local-tts.mjs: $launcherFile" -ForegroundColor Green
|
|
204
217
|
|
|
205
|
-
# ---- 6.
|
|
218
|
+
# ---- 6. 完成提示(命令只在路径含空格时才加引号;插件已能正确处理引号)----
|
|
206
219
|
Write-Host "`n==== 安装完成 ====" -ForegroundColor Cyan
|
|
207
220
|
$nodeExe = (Get-Command node).Source
|
|
208
|
-
$
|
|
221
|
+
$nodePart = if ($nodeExe -match ' ') { "`"$nodeExe`"" } else { $nodeExe }
|
|
222
|
+
$launcherPart = if ($launcherFile -match ' ') { "`"$launcherFile`"" } else { $launcherFile }
|
|
223
|
+
$cmdLine = "$nodePart $launcherPart"
|
|
209
224
|
Write-Host "请到 dsh 设置 → 语音服务 → 本地 TTS,把下面这行填进「本地命令」:"
|
|
210
225
|
Write-Host ""
|
|
211
226
|
Write-Host " $cmdLine" -ForegroundColor Green
|