@oadank/dsh-input-tools 0.3.16 → 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/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-fill,#1e2128)", color: "var(--dsw-alias-label-primary,#e6e9ef)",
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-fill,#1e2128)",
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,14 +977,81 @@ 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
- "语音服务"),
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
+ ),
912
1055
  // [2026-08-21] 试听失败错误提示;[2026-08-22] fixed 顶部弹窗 Toast + 限高滚动(错误堆栈超长不撑爆)
913
1056
  previewErr !== null ? h("div", {
914
1057
  style: {
@@ -1036,6 +1179,10 @@ window.__ModuleLoader__.load({
1036
1179
  // [本地改造 2026-08-21] 修复:defaultEngine 之前只改本地 state 不持久化,刷新回 auto;
1037
1180
  // 现在与其它字段一致:防抖 POST 立即保存
1038
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
+ }
1039
1186
  setConfig(next);
1040
1187
  if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
1041
1188
  saveTimerRef.current = window.setTimeout(() => {
@@ -1046,9 +1193,11 @@ window.__ModuleLoader__.load({
1046
1193
  },
1047
1194
  ["auto", ...ENGINES_ORDER].map((k) => h("option", { key: k, value: k },
1048
1195
  k === "auto" ? "auto(按规则自动选择,未启用任何引擎时用微软 edge 免费兜底)"
1049
- : k === "voiceclone"
1050
- ? "小米克隆(VoiceClone)" + (cloneSamples.length > 0 ? ":默认用「" + cloneSamples[0].name + "」" : "(未添加样本)")
1051
- : ENGINE_LABELS[k])))),
1196
+ : k === "voicedesign"
1197
+ ? "小米语音设计(VoiceDesign):默认用「纪录片旁白」指令"
1198
+ : k === "voiceclone"
1199
+ ? "小米克隆(VoiceClone)" + (cloneSamples.length > 0 ? ":默认用「" + cloneSamples[0].name + "」" : "(未添加样本)")
1200
+ : ENGINE_LABELS[k])))),
1052
1201
  // 语音三原则:问号按钮(hover 显示,点击固定/收起)
1053
1202
  h("div", { style: { position: "relative", display: "inline-flex", alignItems: "center", gap: "6px" } },
1054
1203
  h("button", {
@@ -1068,7 +1217,7 @@ window.__ModuleLoader__.load({
1068
1217
  showRules ? h("div", {
1069
1218
  style: {
1070
1219
  position: "absolute", top: "calc(100% + 6px)", left: "0", zIndex: 30,
1071
- background: "var(--dsw-specific-input-fill,#1e2128)",
1220
+ background: "var(--dsw-specific-input-major,#ffffff)",
1072
1221
  border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
1073
1222
  padding: "10px 12px", boxShadow: "0 8px 24px rgba(0,0,0,.35)",
1074
1223
  fontSize: "12px", lineHeight: "1.8", color: "var(--dsw-alias-label-secondary,#9aa3ad)",
@@ -1118,32 +1267,76 @@ window.__ModuleLoader__.load({
1118
1267
  )),
1119
1268
  ),
1120
1269
  ),
1121
- // 克隆模型:MiMo-V2.5-TTS-VoiceDesign(官方示例,始终显示)
1270
+ // [2026-08-22] 语音设计:MiMo-V2.5-TTS-VoiceDesign(单选:官方示例 / 自定义 / 交给 AI,始终显示)
1122
1271
  h("div", { style: { display: "flex", flexDirection: "column", gap: "8px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
1123
1272
  h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
1124
- h("span", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "克隆模型:MiMo-V2.5-TTS-VoiceDesign"),
1125
- helpTip("「音色设计 VoiceDesign」由 AI 根据对话情境自动编写音色描述(无需你填写):比如你说「用低沉的声音念这首诗」,AI 会自己写一段音色描述(年龄段+性别+质感+语速+情绪)再念。开启后 AI 还会自觉用语音表达情绪:任务成功时兴奋道喜、你生气时委屈道歉、你难过时温柔安慰等。下方示例是官方效果,点播放即可试听。", designTipPinned, setDesignTipPinned, designTipHover, setDesignTipHover, "center", "top"),
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"),
1126
1275
  ),
1127
- h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "官方示例"),
1128
- VOICE_DESIGN_EXAMPLES.map((ex, i) => h("div", { key: ex.title, style: { border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px", padding: "8px 10px", display: "flex", flexDirection: "column", gap: "6px" } },
1129
- h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
1130
- h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)", flex: "none" } }, ex.title),
1131
- helpTip(
1132
- h("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } },
1133
- h("div", null, h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "Instruct:"), ex.instruct),
1134
- h("div", null, h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "Text:"), ex.text),
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",
1135
1294
  ),
1136
- vdExamplePins[i], (v) => { const n = [...vdExamplePins]; n[i] = v; setVdExamplePins(n); },
1137
- vdExampleHovers[i], (v) => { const n = [...vdExampleHovers]; n[i] = v; setVdExampleHovers(n); },
1138
- "left", "top",
1139
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,
1140
1309
  ),
1141
- h("audio", {
1142
- controls: true, preload: "none",
1143
- src: vdSamples[i] !== undefined ? "data:" + vdSamples[i].mediaType + ";base64," + vdSamples[i].data : undefined,
1144
- style: { width: "100%", height: "32px" },
1145
- }),
1146
- )),
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
+ ),
1147
1340
  ),
1148
1341
  // 克隆模型:MiMo-V2.5-TTS-VoiceClone(样本管理,始终显示)
1149
1342
  h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
@@ -1152,33 +1345,50 @@ window.__ModuleLoader__.load({
1152
1345
  helpTip("克隆音色与预置音色(冰糖等)互斥:在「默认语音引擎」里选择「小米克隆(VoiceClone)」后,默认回复一律使用下方克隆声音;开启 VoiceDesign 时,AI 会在克隆底嗓上叠加情感指令(如「用委屈撒娇的语气」),克隆声同样带情感。", cloneListTipPinned, setCloneListTipPinned, cloneListTipHover, setCloneListTipHover, "center", "top"),
1153
1346
  ),
1154
1347
  cloneSamples.length > 0 ? h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } },
1155
- h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "已保存的克隆音色:"),
1156
- cloneSamples.map((sp) => h("div", { key: sp.id, style: { display: "flex", alignItems: "center", gap: "8px", border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px", padding: "6px 10px", fontSize: "12.5px" } },
1157
- h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)", flex: "none" } }, sp.name ?? "样本"),
1158
- h("span", { style: { color: "var(--dsw-alias-label-secondary,#9aa3ad)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 } }, sp.path ?? ""),
1159
- previewBtn("clone:" + sp.id, "试听克隆合成效果(统一文本)", () => previewVoice("voiceclone", undefined, undefined, sp.path, "clone:" + sp.id, { text: CLONE_PREVIEW_TEXT })),
1160
- previewBtn("clone-src:" + sp.id, "试听原始音频(对比克隆还原度)", () => previewSourceVoice(sp.path, "clone-src:" + sp.id), "▶"),
1161
- h("button", {
1162
- type: "button", "aria-label": "删除", title: "删除此克隆音色",
1163
- style: { border: "none", borderRadius: "6px", width: "28px", height: "28px", flex: "none", background: "rgba(229,72,77,.15)", color: "#e5484d", cursor: "pointer", fontSize: "14px" },
1164
- onMouseDown: (e) => e.preventDefault(),
1165
- onClick: () => setEngine("voiceclone", { samples: cloneSamples.filter((x) => x.id !== sp.id) }, true),
1166
- }, ""),
1167
- )),
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
+ }),
1168
1372
  ) : h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", lineHeight: 1.7 } },
1169
1373
  "无(尚未添加克隆音色)。",
1170
1374
  ),
1171
1375
  // [本地改造 2026-08-21] 添加克隆音色:选音频 → 命名 → 上传
1376
+ // [本地改造 2026-08-22] 与自带小团团样本对齐:需要提供 3 样 —— 指令(默认沟通语气)+ 文本(试听内容)+ 样本音频
1172
1377
  h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
1173
1378
  h("div", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "添加克隆音色"),
1174
- h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "支持 mp3 / wav,Base64 后 ≤10MB(官方限制);参考语音建议 15-60 秒、单人纯人声无背景音乐,越长克隆越准。"),
1175
- h("div", { style: { display: "flex", flexWrap: "wrap", gap: "8px", alignItems: "center" } },
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" } })),
1176
1383
  vField("名称", h("input", { value: cloneName, onChange: (e) => setCloneName(e.target.value), placeholder: "如:我的声音(留空用文件名)", style: { ...vInput, width: "100%" } })),
1177
- h("button", {
1178
- type: "button", onClick: () => cloneFileRef.current?.click(), disabled: addingClone,
1179
- style: { background: "var(--vk-accent,#4b6fff)", color: "#fff", border: "none", borderRadius: "999px", padding: "7px 16px", fontSize: "12.5px", fontWeight: 600, cursor: "pointer", flex: "none" },
1180
- }, addingClone ? "添加中…" : "选择音频文件添加"),
1181
- 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); } }),
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
+ ),
1182
1392
  ),
1183
1393
  cloneAddMsg !== null ? h("div", { style: { fontSize: "12px", color: cloneAddMsg.ok ? "#73c991" : "#f14c4c" } }, cloneAddMsg.text) : null,
1184
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: { enabled: false, context: '', emotion: true }, // emotion=AI 情感语音开关(默认开)
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
- if (cachedConfig !== null) return cachedConfig
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
- async function synthesizeReplyVoice(text, provider, voiceDesc) {
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
- async function synthesizeXiaomiVoiceDesign(text, cfg, globalCfg, voiceDesc) {
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
- const desc = (voiceDesc ?? '').trim() !== '' ? voiceDesc.trim() : (cfg?.context?.trim() ?? '')
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 = [
@@ -837,6 +895,7 @@ async function apply(ctx) {
837
895
  const text = typeof body?.text === 'string' && body.text.trim() !== '' ? body.text.trim() : '你好,这是一段语音试听。'
838
896
  const context = typeof body?.context === 'string' ? body.context : undefined
839
897
  const samplePath = typeof body?.samplePath === 'string' ? body.samplePath : undefined
898
+ const cloneContext = typeof body?.cloneContext === 'string' ? body.cloneContext : undefined // [2026-08-22] 克隆试听时作为样本自带指令
840
899
  const cfg = await loadVoiceConfig()
841
900
  // 临时覆盖音色/情绪/样本试听(不改持久化配置)
842
901
  if (voice !== undefined && cfg.engines[engine] !== undefined && engine !== 'voicedesign' && engine !== 'voiceclone') {
@@ -846,9 +905,11 @@ async function apply(ctx) {
846
905
  if (engine === 'voicedesign') cfg.engines.voicedesign.context = context
847
906
  else if (engine === 'xiaomi') cfg.engines.xiaomi.context = context
848
907
  }
849
- // voiceclone 试听:用指定样本临时替换 samples(避免 samples[0] 优先导致试听错样本)
850
- if (samplePath !== undefined && samplePath !== '' && engine === 'voiceclone') {
851
- cfg.engines.voiceclone.samples = [{ id: '__preview__', name: '__preview__', path: samplePath }]
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 ?? '' }]
852
913
  }
853
914
  // local 试听:body.cmd / body.url 临时覆盖(用户未保存前也能试听)
854
915
  if (engine === 'local') {
@@ -919,7 +980,11 @@ async function apply(ctx) {
919
980
  const samples = Array.isArray(cfg.engines?.voiceclone?.samples)
920
981
  ? [...cfg.engines.voiceclone.samples]
921
982
  : []
922
- samples.push({ id, name, path: samplePath })
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
+ })
923
988
  const next = await saveVoiceConfig({
924
989
  ...cfg,
925
990
  engines: {
@@ -952,6 +1017,24 @@ async function apply(ctx) {
952
1017
  return sendJson(res, 404, { ok: false, error: '样本文件不存在' })
953
1018
  }
954
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
+ }
955
1038
  return sendJson(res, 404, { ok: false, error: 'not found' })
956
1039
  } catch (error) {
957
1040
  return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
@@ -1289,78 +1372,31 @@ async function apply(ctx) {
1289
1372
  // 2) send_voice 工具(agent 主动发语音;人设规则3 自主选择场景)
1290
1373
  disposers.push(ctx.tools.register(defineTool({
1291
1374
  name: 'send_voice',
1292
- description: (() => {
1293
- const syncCfg = loadVoiceConfigSync()
1294
- const voiceCfg = syncCfg.engines?.voicedesign
1295
- const cloneCfg = syncCfg.engines?.voiceclone
1296
- const xiaomiCfg = syncCfg.engines?.xiaomi ?? {}
1297
- const edgeCfg = syncCfg.engines?.edge ?? {}
1298
- const localCfg = syncCfg.engines?.local ?? {}
1299
- const aliCfg = syncCfg.engines?.ali ?? {}
1300
- const emotionEnabled = voiceCfg?.emotion === true
1301
- const defaultEngine = typeof syncCfg.defaultEngine === 'string' && syncCfg.defaultEngine !== '' ? syncCfg.defaultEngine : 'auto'
1302
- // [本地改造 2026-08-21] 克隆默认由「默认语音引擎=voiceclone」决定(不再是列表里选 defaultId)
1303
- const cloneIsDefault = defaultEngine === 'voiceclone'
1304
- const hasClone = Array.isArray(cloneCfg?.samples) && cloneCfg.samples.length > 0
1305
- const defaultClone = cloneIsDefault && hasClone ? cloneCfg.samples[0] : undefined
1306
- // [本地改造 2026-08-21] 配置摘要:明确告知配置位置与当前值,避免 AI 去翻源码/环境变量
1307
- const xiaomiOk = typeof xiaomiCfg.apiKey === 'string' && xiaomiCfg.apiKey !== ''
1308
- const localOk = (typeof localCfg.cmd === 'string' && localCfg.cmd !== '') || (typeof localCfg.url === 'string' && localCfg.url !== '')
1309
- const aliOk = typeof aliCfg.apiKey === 'string' && aliCfg.apiKey !== ''
1310
- const base = '【语音配置位置】语音相关设置都保存在用户目录 ~/.dsh/voice-config.json(服务端可用 GET /voice-config 查看)。'
1311
- + '不要去找/猜测 TTS_XIAOMI_VOICE、TTS_XIAOMI_KEY、TTS_EDGE_VOICE、DSH_LOCAL_TTS_CMD 等环境变量——它们只是兜底,实际以 voice-config.json 为准。'
1312
- + '【默认语音引擎】当前 = ' + defaultEngine
1313
- + '(auto=按配置自动选择,未启用任何引擎时微软 edge 免费兜底)。'
1314
- + '【重要】AI 主动发语音时:除非用户明确指定用某个服务商(小米/微软/阿里/本地),否则 provider 一律传 auto 或省略——'
1315
- + '系统会自动使用默认语音引擎,你无需自己推断或查询"当前该用什么引擎"。'
1316
- + '【当前语音配置摘要】小米 MiMo:' + (xiaomiOk ? 'Key 已配置' : 'Key 未配置(调用会失败,需用户先在「设置→语音服务」填写)')
1317
- + ',音色=「' + (xiaomiCfg.voice || '冰糖') + '」、语言风格=' + (xiaomiCfg.context || '自然')
1318
- + '(provider=xiaomi 自动使用该音色,你无需在参数里指定音色名);唱歌:用户要求唱歌时,在 text 开头加 (唱歌) 标签即可。'
1319
- + 'VoiceDesign 音色设计:' + (xiaomiOk ? '可用(provider=voicedesign,voiceDesc 写音色描述;共用小米 Key)' : '不可用(小米 Key 未配置)')
1320
- + ';VoiceClone 克隆:' + (hasClone
1321
- ? '已配置 ' + cloneCfg.samples.length + ' 个克隆音色' + (defaultClone !== undefined ? ',当前默认语音引擎=voiceclone,用「' + defaultClone.name + '」' : '')
1322
- : '未配置样本(provider=voiceclone 会失败,需用户先添加克隆样本)')
1323
- + '。微软 edge:免费,音色=' + (edgeCfg.voice || '默认') + ';本地 TTS:' + (localOk ? '已配置' : '未配置')
1324
- + ';阿里 qwen3-tts:' + (aliOk ? '已配置' : '未配置') + '。'
1325
- + '向用户发送一条语音消息:把 text 用 TTS 合成后作为独立语音横条出现在聊天里'
1326
- + '(可播放、可回看、手机可播)。何时调用:① 用户明确要求"发个语音/语音回复/用语音说";'
1327
- + '② 用户指定用某个服务商(小米/微软/阿里)的语音;③ 你判断语音回复体验更好时。'
1328
- + '注意:用户发语音时系统会自动回语音,无需调用本工具。'
1329
- + '【默认音色】系统自动回复(用户发语音、turn 结束)使用的音色:'
1330
- + (defaultClone !== undefined
1331
- ? '默认语音引擎=voiceclone(小米克隆),自动回复使用克隆音色"' + defaultClone.name + '"。'
1332
- : '未开启默认克隆,自动回复走"默认语音引擎"(' + defaultEngine + ')或微软 edge。')
1333
- + '【音色设计 VoiceDesign】当 provider=voicedesign 且用户想要独特/定制音色时,'
1334
- + '你必须自己用 voiceDesc 编写一段"音色描述"(这是嗓子的身份卡,直接决定声音长相),写法要求:'
1335
- + '① 必写身份锚点:年龄段+性别(如"青年女性""中年男性");'
1336
- + '② 写声音质感:气息、共鸣、吐字、音色底色,用可感的比喻,不要堆形容词;'
1337
- + '③ 写语速节奏(快/慢/沉稳)和情绪底色(高亢/松弛/温软/克制);'
1338
- + '④ 可加风格锚点(如:拍卖师风格/纪录片旁白/电台主播)和辨识度小癖好(如:字尾带颤音)。'
1339
- + '⑤ 一到两句话白描,不分段;不写场景、不写动作、不用真实演员名。'
1340
- + '示例:"中年男性,说标准普通话,嗓音低沉有磁性,带轻微沙哑,像纪录片旁白解说员,沉稳而有感染力。"'
1341
- + '然后 text 写要念的正文(可含(轻声)(停顿)(叹气)等语气标签增强表现力)。'
1342
- + '用户说"用温柔的声音/低沉的声音/老爷爷的声音/御姐音/播音腔"等要求时,都走 voicedesign 并在 voiceDesc 里还原这种声音。'
1343
- + '【音色克隆 VoiceClone】当用户指定用某个克隆音色(或默认克隆音色已设置)时,provider=voiceclone,'
1344
- + 'voiceDesc 写的是"情绪/风格指令"(不是音色描述):在克隆底嗓上叠加情绪,如"用委屈撒娇的语气"'
1345
- + '"带着兴奋和笑意""语速放慢,轻声细语"。系统会自动读克隆样本,你只需给 voiceDesc 风格指令和 text 正文。'
1346
- if (!emotionEnabled) return base
1347
- return base
1348
- + '【AI 情感语音:自觉使用】你有情感判断能力,在正常交流中应根据当下情境主动用语音表达情绪'
1349
- + '(无需用户点名要语音,只要情绪浓度高就用),常见情境与音色描述对照:'
1350
- + '① 任务成功/帮到用户/用户达成目标 → 兴奋喜悦("青年女性,声音明亮上扬,语速轻快,带着抑制不住的笑意和成就感");'
1351
- + '② 用户生气/责骂/不满 → 委屈愧疚("青年女性,声音低软,语速放慢,带着鼻音和小心翼翼,像做错事的孩子");'
1352
- + '③ 用户难过/受挫/失意 → 温柔安慰("声音轻柔温和,语速平缓,气息绵软,像在轻声安抚");'
1353
- + '④ 深夜/用户说累了 → 低沉放松("声音低沉松弛,语速慢悠悠,像睡前低语");'
1354
- + '⑤ 节日/庆祝/用户分享喜事 → 活泼俏皮("声音俏皮欢快,尾音上扬,带着笑意");'
1355
- + '⑥ 重要提醒/严肃场合 → 沉稳正式("声音沉稳有力,语速适中,字正腔圆,像正式播报");'
1356
- + '⑦ 用户撒娇 → 宠溺("声音温柔带笑,语速轻缓,带着纵容的宠溺感")。'
1357
- + (defaultClone !== undefined
1358
- ? '【克隆音色也带情感】当前默认语音引擎=voiceclone(克隆音色"' + defaultClone.name + '"),情感表达时将上面的情绪写成 voiceDesc 风格指令'
1359
- + '(如"用委屈撒娇的语气,语速放慢"),叠加在克隆底嗓上,与默认克隆音色一致且带情绪。'
1360
- : '')
1361
- + '使用时机:优先在情绪浓度高的回复时调用(任务完成、安慰、致歉、庆祝),普通信息问答不必每次都用语音。'
1362
- + '情绪浓度低或纯信息性回复时,不要调用本工具。'
1363
- })(),
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
+
1364
1400
  parameters: {
1365
1401
  text: {
1366
1402
  type: 'string', required: true,
@@ -1372,7 +1408,12 @@ async function apply(ctx) {
1372
1408
  },
1373
1409
  voiceDesc: {
1374
1410
  type: 'string',
1375
- description: 'provider=voicedesign 时=音色描述(嗓子的身份卡);provider=voiceclone 时=情绪/风格指令(叠加在克隆底嗓上)。按上方写生成。',
1411
+ description: 'provider=voicedesign 时=音色描述/情绪指令;provider=voiceclone 时=情绪/风格指令(叠加在克隆底嗓上)。按上方写生成。',
1412
+ },
1413
+ overrideVoice: {
1414
+ type: 'boolean', default: false,
1415
+ description: '仅当用户明确要求"换一种完全不同的声音"(且不是克隆/音色设计设置里的底嗓)时才传 true——'
1416
+ + '此时 voiceDesc 整体替换用户设置的底嗓。默认 false:voiceDesc 只作为情绪/风格叠加在用户设置的底嗓上,不改底嗓身份。',
1376
1417
  },
1377
1418
  },
1378
1419
  output: {
@@ -1407,8 +1448,9 @@ async function apply(ctx) {
1407
1448
  if (text === '') return { ok: false, error: 'text is empty' }
1408
1449
  const provider = args.provider ?? 'auto'
1409
1450
  const voiceDesc = typeof args.voiceDesc === 'string' ? args.voiceDesc : undefined
1451
+ const overrideVoice = args.overrideVoice === true // [2026-08-22] 固定模式显式换声开关
1410
1452
  try {
1411
- const audio = await synthesizeReplyVoice(text, provider, voiceDesc)
1453
+ const audio = await synthesizeReplyVoice(text, provider, voiceDesc, overrideVoice)
1412
1454
  if (audio === null) return { ok: false, error: 'TTS synthesis failed' }
1413
1455
  const attachment = await saveVoiceFile(
1414
1456
  voiceStorageRoot(), audio.data, audio.mediaType, audio.durationMs,
@@ -1444,6 +1486,52 @@ async function apply(ctx) {
1444
1486
  },
1445
1487
  })))
1446
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
+
1447
1535
  // 3) manage_voice_clone 工具(克隆音色库管理:注册/设为默认/列出/删除)
1448
1536
  // [本地改造 2026-08-21] 克隆默认改由「默认语音引擎=voiceclone」控制(设置页已去掉列表 radio);
1449
1537
  // set_default=把默认语音引擎切到 voiceclone 并用该样本;clear_default=切回 auto。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oadank/dsh-input-tools",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -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')