@oadank/dsh-input-tools 0.3.19 → 0.3.21

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/index.js CHANGED
@@ -1,1738 +1,1932 @@
1
- /**
2
- * dsh-input-tools — 语音能力一体化插件(host 半:语音工具/TTS/ASR/克隆/自动回复)
3
- *
4
- * 能力:
5
- * 1) send_voice 工具(agent 主动发语音,任一新会话自动注入)
6
- * 2) turn/end 自动语音回复(用户本轮发过语音 / 文本明确要求语音 / 指定服务商)
7
- * 3) TTS 引擎(可配置,默认 auto=小米优先→edge 降级):
8
- * - edge 微软免费(edge-tts,音色可配)
9
- * - xiaomi 小米 mimo-v2.5-tts(8 预置音色 + 唱歌 + 自然语言风格)
10
- * - voicedesign 小米 mimo-v2.5-tts-voicedesign(文本描述定制音色)
11
- * - voiceclone 小米 mimo-v2.5-tts-voiceclone(音频样本复刻音色)
12
- * - local 本地 MeloTTS(HTTP 常驻服务优先,CMD 兜底)
13
- * - ali 阿里 qwen3-tts-flash(dashscope,音色可配)
14
- * 4) voice 对象内容寻址落盘(DSH_HOME/attachments/v1/objects,与图片同池)
15
- * 5) 配置中心:~/.dsh/voice-config.json(环境变量 → 配置 → 默认值 三级回退)
16
- * 6) HTTP 路由:GET/POST /voice-config(设置页读写)+ GET /voice-config/engines(引擎元数据)
17
- *
18
- * 原代码在 api-proxy.ts 中已删除,本文件为唯一实现;voice.ts 仍保留给
19
- * voiceAsr/voiceTts RPC(编辑器内转写/合成)使用。
20
- */
21
-
22
- import { createHash, randomUUID } from 'node:crypto'
23
- import { mkdir, open, readFile, unlink, writeFile, copyFile, stat } from 'node:fs/promises'
24
- import { constants, readFileSync } from 'node:fs'
25
- import { homedir } from 'node:os'
26
- import { fileURLToPath } from 'node:url'
27
- import { join, resolve, sep } from 'node:path'
28
- import { execFileSync } from 'node:child_process'
29
- import { defineTool } from '@deepseek-ai/dsh-tools'
30
- import { edgeTts } from './edge-tts.js'
31
-
32
- const name = 'dsh-input-tools'
33
- const inject = ['tools', 'webServer']
34
-
35
- export { name, inject }
36
-
37
- // ──────────────────────────────────────────────────────────────
38
- // [0.3.4] 自带素材(下载即用):克隆样本 + VoiceDesign 示例音频打进 npm 包 assets/,
39
- // 首次加载自动拷贝到 DSH_HOME 并注册,不再依赖"手动上传/在线生成"。
40
- // ──────────────────────────────────────────────────────────────
41
- const PLUGIN_ROOT = join(fileURLToPath(import.meta.url), '..', '..') // .../dsh-input-tools
42
- const ASSETS_DIR = join(PLUGIN_ROOT, 'assets')
43
- const BUNDLED_CLONE_ID = '8da38fcc-b041-4f5b-86b9-901956016f89'
44
- const BUNDLED_CLONE_SAMPLE = {
45
- id: BUNDLED_CLONE_ID,
46
- name: '小团团(60秒长样本)',
47
- context: '一个魔性的少女萝莉音,说话自带沙雕搞怪和无厘头气质,像在撒娇又像在耍宝,情绪起伏很大:前一句还奶声奶气地撒娇卖萌,后一句就突然拔高音量夸张卖惨耍赖,再下一秒又贱兮兮地坏笑。尾音拖长上扬,带着气音和魔性笑声,喜欢用「臭猪」「你凶我」「哼」「嘿嘿嘿」这类咋咋呼呼的用词,语速忽快忽慢、节奏跳跃,吐字软糯清晰,傻白甜又可爱,让人听了忍不住想笑',
48
- }
49
- const VOICE_DESIGN_SAMPLE_KEYS = ['asmr', 'docu', 'elder']
50
-
51
- // [2026-08-22] AI 自动模式的年龄感 6 档(用户实时可改,禁止自由文本)
52
- const AI_AGE_LABELS = { infant: '婴儿感', child: '幼儿感', teen: '少年感', young: '青年感', middle: '中年感', old: '老年感' }
53
-
54
- // [2026-08-22] 年龄×性别 → 无歧义身份短语(XDN 实测: "老年感+女孩"分维度拼接自相矛盾,
55
- // "女孩"是中心词→年龄被降级→萝莉化;且"忽略性别/年龄"注把"沙哑/苍老"等最强质感词删了)。
56
- // 改为"老年女性/小女孩/少女"这类中心词明确的合并短语,年龄不会再被降级。
57
- function ageGenderIdentity(ageKey, genderKey) {
58
- const male = genderKey === 'male'
59
- const female = genderKey === 'female'
60
- switch (ageKey) {
61
- case 'infant': return male ? '男婴' : female ? '女婴' : '婴儿'
62
- case 'child': return male ? '小男孩' : female ? '小女孩' : '小孩'
63
- case 'teen': return male ? '少年' : female ? '少女' : '少年'
64
- case 'young': return male ? '青年男性' : female ? '青年女性' : '青年人'
65
- case 'middle': return male ? '中年男性' : female ? '中年女性' : '中年人'
66
- case 'old': return male ? '老年男性' : female ? '老年女性' : '老年人'
67
- default: return male ? '男性' : female ? '女性' : ''
68
- }
69
- }
70
-
71
- let bundledInitDone = false
72
- /** 首次加载把自带素材落地到 DSH_HOME:克隆样本 mp3 拷贝 + 首次安装自动注册小团团。 */
73
- async function ensureBundledAssets(config, parsed) {
74
- if (bundledInitDone) return config
75
- bundledInitDone = true
76
- try {
77
- const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
78
- const cloneDir = join(homeDir, 'voiceclone-samples')
79
- const dstClone = join(cloneDir, BUNDLED_CLONE_ID + '.mp3')
80
- try {
81
- await mkdir(cloneDir, { recursive: true })
82
- await copyFile(join(ASSETS_DIR, 'voiceclone-samples', BUNDLED_CLONE_ID + '.mp3'), dstClone)
83
- // [2026-08-22] 预生成的合成试听录音(静态文件,播放免联网;与 VoiceDesign 官方示例同类)
84
- await copyFile(join(ASSETS_DIR, 'voiceclone-samples', BUNDLED_CLONE_ID + '-preview.mp3'), join(cloneDir, BUNDLED_CLONE_ID + '-preview.mp3'))
85
- } catch { /* 包内素材缺失或拷贝失败:跳过(不阻塞启动) */ }
86
- // [2026-08-22] 像素级反推提示词(vision-qa GENERAL_SINGLE 吸收)落地到
87
- // DSH_HOME/visionqa-reverse-prompt.txt——fork 的图片转文本引导会指示模型在
88
- // 反推任务时读取该文件并作为 modlens_read_image 的 prompt 参数传入。
89
- try {
90
- await copyFile(join(ASSETS_DIR, 'reverse-prompt.txt'), join(homeDir, 'visionqa-reverse-prompt.txt'))
91
- } catch { /* 素材缺失跳过 */ }
92
- // 仅"首次安装"(配置里还没有 voiceclone 键)时注册自带样本;用户删光的 [] 不强制
93
- const parsedHasClone = parsed !== null && typeof parsed === 'object' && parsed.engines?.voiceclone !== undefined
94
- const samples = config?.engines?.voiceclone?.samples
95
- if (!parsedHasClone && (!Array.isArray(samples) || samples.length === 0)) {
96
- config.engines.voiceclone = { ...config.engines.voiceclone, enabled: true, samples: [{ ...BUNDLED_CLONE_SAMPLE, path: dstClone }] }
97
- await saveVoiceConfig(config)
98
- }
99
- } catch { /* 初始化失败不阻塞 */ }
100
- return config
101
- }
102
-
103
- // ──────────────────────────────────────────────────────────────
104
- // 配置中心:~/.dsh/voice-config.json
105
- // ──────────────────────────────────────────────────────────────
106
- const CONFIG_PATH = resolve(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'voice-config.json'))
107
-
108
- function defaultVoiceConfig() {
109
- return {
110
- defaultEngine: 'auto',
111
- engines: {
112
- edge: { enabled: true, voice: 'zh-CN-XiaoxiaoNeural' },
113
- xiaomi: {
114
- enabled: true,
115
- apiKey: '',
116
- baseUrl: 'https://api.xiaomimimo.com/v1',
117
- voice: '冰糖',
118
- singing: false,
119
- context: '',
120
- },
121
- voicedesign: {
122
- enabled: false,
123
- mode: 'docu', // [2026-08-22] 单选: asmr|docu|elder|custom|ai(官方示例/自定义/交给 AI 自动发挥)
124
- context: '',
125
- emotion: false, // AI 情感语音(mode=ai 时自动开;固定示例/自定义模式关闭,保证音色一致)
126
- lockGender: true, lockTimbre: true, lockAge: true, // [2026-08-22] AI 自动模式下的稳定锚点锁定
127
- aiGender: 'female', aiAge: 'young', // [2026-08-22] AI 自动模式固定值:性别(女/男);年龄感 6 档 infant/child/teen/young/middle/old
128
- }, // emotion=AI 情感语音开关(默认开)
129
- voiceclone: { enabled: false, samples: [], samplePath: '', context: '', defaultId: '' }, // [本地改造 2026-08-21] defaultId 已废弃,默认克隆由 defaultEngine=voiceclone 控制
130
- local: { enabled: true, url: '', cmd: '' },
131
- ali: {
132
- enabled: false,
133
- apiKey: '',
134
- baseUrl: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation',
135
- voice: 'Cherry',
136
- },
137
- asr: {
138
- enabled: true,
139
- mode: 'service', // service=本地常驻服务 / cmd=本地命令 / api=在线 API
140
- url: 'http://127.0.0.1:18790', // sherpa-onnx 常驻服务(nssm: asr)
141
- cmd: 'C:\\D\\opt\\sherpa-onnx\\bin\\sherpa-onnx-offline.exe --tokens=C:\\D\\opt\\sherpa-onnx\\models\\sensevoice-int8\\tokens.txt --sense-voice-model=C:\\D\\opt\\sherpa-onnx\\models\\sensevoice-int8\\model.int8.onnx --num-threads=4',
142
- apiKey: '',
143
- apiBaseUrl: 'https://api.xiaomimimo.com/v1', // 小米 mimo-v2.5-asr;填 openai 地址则走 Whisper 风格
144
- },
145
- },
146
- }
147
- }
148
-
149
- function deepMerge(base, patch) {
150
- if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
151
- return patch === undefined ? base : patch
152
- }
153
- const out = { ...base }
154
- for (const [k, v] of Object.entries(patch)) {
155
- out[k] = v !== null && typeof v === 'object' && !Array.isArray(v)
156
- && base?.[k] !== null && typeof base?.[k] === 'object'
157
- ? deepMerge(base[k], v)
158
- : v
159
- }
160
- return out
161
- }
162
-
163
- let cachedConfig = null
164
- let cachedMtimeMs = -1
165
- async function loadVoiceConfig() {
166
- // [2026-08-22] 实时读取:配置文件 mtime 变化(保存/外部修改)即重读,杜绝进程内旧缓存
167
- let mtimeMs = -1
168
- try { mtimeMs = (await stat(CONFIG_PATH)).mtimeMs } catch { /* 文件不存在 */ }
169
- if (cachedConfig !== null && mtimeMs === cachedMtimeMs) return cachedConfig
170
- let parsed = {}
171
- try {
172
- parsed = JSON.parse(await readFile(CONFIG_PATH, 'utf8'))
173
- } catch { /* 首次无配置 */ }
174
- cachedConfig = deepMerge(defaultVoiceConfig(), parsed)
175
- cachedMtimeMs = mtimeMs
176
- // [0.3.4] 自带素材初始化(拷贝克隆样本 + 首次安装自动注册小团团)
177
- await ensureBundledAssets(cachedConfig, parsed)
178
- // 环境变量覆盖(兼容旧配置;显式配置值优先于 env)
179
- const env = process.env
180
- if (env.TTS_XIAOMI_KEY !== undefined && cachedConfig.engines.xiaomi.apiKey === '') cachedConfig.engines.xiaomi.apiKey = env.TTS_XIAOMI_KEY
181
- if (env.TTS_XIAOMI_VOICE !== undefined && cachedConfig.engines.xiaomi.voice === '冰糖') cachedConfig.engines.xiaomi.voice = env.TTS_XIAOMI_VOICE
182
- if (env.TTS_XIAOMI_BASE_URL !== undefined) cachedConfig.engines.xiaomi.baseUrl = env.TTS_XIAOMI_BASE_URL
183
- if (env.TTS_EDGE_VOICE !== undefined && cachedConfig.engines.edge.voice === 'zh-CN-XiaoxiaoNeural') cachedConfig.engines.edge.voice = env.TTS_EDGE_VOICE
184
- if (env.DSH_LOCAL_TTS_CMD !== undefined && cachedConfig.engines.local.cmd === '') cachedConfig.engines.local.cmd = env.DSH_LOCAL_TTS_CMD
185
- return cachedConfig
186
- }
187
-
188
- /** 同步读配置:供 defineTool 的 description 等同步上下文使用
189
- * (注意:loadVoiceConfig async,在同步处直接用会拿到 Promise → 字段全 undefined)。 */
190
- function loadVoiceConfigSync() {
191
- if (cachedConfig !== null) return cachedConfig
192
- let parsed = {}
193
- try {
194
- parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
195
- } catch { /* 首次无配置 */ }
196
- return deepMerge(defaultVoiceConfig(), parsed)
197
- }
198
-
199
- async function saveVoiceConfig(config) {
200
- cachedConfig = deepMerge(defaultVoiceConfig(), config)
201
- await mkdir(join(CONFIG_PATH, '..'), { recursive: true })
202
- await writeFile(CONFIG_PATH, JSON.stringify(cachedConfig, null, 2), 'utf8')
203
- try { cachedMtimeMs = (await stat(CONFIG_PATH)).mtimeMs } catch { /* 忽略 */ }
204
- return cachedConfig
205
- }
206
-
207
- // ──────────────────────────────────────────────────────────────
208
- // 语音对象存储(内容寻址,与图片附件同池:DSH_HOME/attachments/v1/objects)
209
- // ──────────────────────────────────────────────────────────────
210
- const MAX_VOICE_BYTES = 25 * 1024 * 1024
211
-
212
- // [2026-08-21] 语音气泡(聊天界面 DOM 注入):录音暂存 DSH_HOME/voice-outbox/
213
- const VOICE_OUTBOX_EXT = {
214
- 'audio/webm': 'webm',
215
- 'audio/ogg': 'ogg',
216
- 'audio/mp4': 'mp4',
217
- 'audio/m4a': 'm4a',
218
- 'audio/wav': 'wav',
219
- 'audio/mpeg': 'mp3',
220
- }
221
- const VOICE_OUTBOX_MIME = {
222
- webm: 'audio/webm', ogg: 'audio/ogg', mp4: 'audio/mp4', m4a: 'audio/m4a',
223
- wav: 'audio/wav', mp3: 'audio/mpeg',
224
- }
225
-
226
- /** 检测当前 dsh 的 connection 契约是否原生支持 voice content(rc.8 本地改造有;npm 官方版无)。
227
- * 优先从 dsh 进程实际运行的位置解析(dev 仓库 cwd / npm 全局),避免误报。 */
228
- /** 从音频文件头嗅探媒体类型(对象存储无扩展名,TTS 输出可能是 wav/mp3)。 */
229
- function sniffAudioType(bytes) {
230
- if (bytes.length >= 4 && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) return 'audio/wav'
231
- if (bytes.length >= 3 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) return 'audio/mpeg'
232
- if (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) return 'audio/mpeg'
233
- if (bytes.length >= 4 && bytes[0] === 0x4f && bytes[1] === 0x67 && bytes[2] === 0x67 && bytes[3] === 0x53) return 'audio/ogg'
234
- return 'audio/mpeg'
235
- }
236
-
237
- async function detectVoiceContractSupport() {
238
- const markers = ['literal("voice")', "literal('voice')"]
239
- const containsVoice = (s) => markers.some((m) => s.includes(m))
240
- // 1) dev 仓库(本机 lecoo:dsh 由 apps/cli tsx 直接跑,cwd=仓库根)
241
- for (const rel of [
242
- join('packages', 'client', 'connection', 'lib', 'client.js'),
243
- join('node_modules', '@deepseek-ai', 'dsh-client-connection', 'lib', 'client.js'),
244
- ]) {
245
- try {
246
- if (containsVoice(readFileSync(join(process.cwd(), rel), 'utf8'))) return true
247
- } catch { /* 下一个候选 */ }
248
- }
249
- // 2) npm 安装(XDN:dsh 在全局 node_modules,从插件解析链向上找)
250
- try {
251
- const { createRequire } = await import('node:module')
252
- const require = createRequire(import.meta.url)
253
- const p = require.resolve('@deepseek-ai/dsh-client-connection/lib/client.js')
254
- if (containsVoice(readFileSync(p, 'utf8'))) return true
255
- } catch { /* 找不到 */ }
256
- return false
257
- }
258
-
259
- function voiceStorageRoot() {
260
- const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
261
- return resolve(join(home, 'attachments', 'v1'))
262
- }
263
-
264
- function objectPath(root, sha256) {
265
- return join(root, 'objects', sha256.slice(0, 2), sha256)
266
- }
267
-
268
- async function saveVoiceFile(root, data, mediaType, durationMs) {
269
- if (data.byteLength > MAX_VOICE_BYTES) {
270
- throw new Error(`Voice object exceeds the ${MAX_VOICE_BYTES}-byte limit.`)
271
- }
272
- const sha256 = createHash('sha256').update(data).digest('hex')
273
- const bucket = join(root, 'objects', sha256.slice(0, 2))
274
- const target = objectPath(root, sha256)
275
- await mkdir(bucket, { recursive: true, mode: 0o700 })
276
- let handle
277
- try {
278
- handle = await open(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
279
- await handle.writeFile(data)
280
- await handle.close()
281
- handle = undefined
282
- } catch (error) {
283
- if (handle !== undefined) await handle.close().catch(() => {})
284
- if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) {
285
- throw new Error(`Unable to persist voice object: ${String(error)}`, { cause: error })
286
- }
287
- }
288
- return {
289
- voiceId: `sha256:${sha256}`,
290
- mediaType,
291
- bytes: data.byteLength,
292
- ...(durationMs === undefined ? {} : { durationMs }),
293
- }
294
- }
295
-
296
- // ──────────────────────────────────────────────────────────────
297
- // TTS 引擎
298
- // ──────────────────────────────────────────────────────────────
299
- /** 解析 ffmpeg 可执行文件:环境变量显式指定 > PATH 探测(where/which)> 兜底已知安装位置。
300
- * [本地改造 2026-08-21] 修复:原来硬编码本机路径,换机器必挂。 */
301
- function resolveFfmpegBin() {
302
- if (typeof process.env.DSH_VOICE_FFMPEG_BIN === 'string' && process.env.DSH_VOICE_FFMPEG_BIN.trim() !== '') {
303
- return process.env.DSH_VOICE_FFMPEG_BIN.trim()
304
- }
305
- try {
306
- // Windows: where ffmpeg;POSIX: which ffmpeg
307
- const probe = process.platform === 'win32' ? 'where' : 'which'
308
- const out = execFileSync(probe, ['ffmpeg'], { windowsHide: true, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] })
309
- const first = out.split(/\r?\n/).map((l) => l.trim()).find((l) => l !== '')
310
- if (first !== undefined) return first
311
- } catch { /* 不在 PATH */ }
312
- return 'C:\\Users\\oadan\\AppData\\Local\\Microsoft\\WinGet\\Links\\ffmpeg.exe'
313
- }
314
- const FFMPEG_BIN = resolveFfmpegBin()
315
-
316
- /** 统一入口:provider 引擎;auto 配置 defaultEngine,失败沿降级链(最后兜底微软 edge)。
317
- * voiceDesc 为动态音色描述(仅 voicedesign 用):AI 对话中生成,覆盖配置里的默认音色描述。
318
- * [本地改造 2026-08-21] 克隆不再隐式优先:默认克隆由「默认语音引擎=voiceclone」控制,或显式 provider=voiceclone。
319
- * [2026-08-22] overrideVoice=true:固定模式(示例/自定义)下 voiceDesc 整体替换底嗓(用户明确要求换声);默认 false=voiceDesc 作为情绪/风格叠加在底嗓上。 */
320
- async function synthesizeReplyVoice(text, provider, voiceDesc, overrideVoice) {
321
- const cfg = await loadVoiceConfig()
322
- const speak = stripMarkdown(text)
323
- const engine = provider ?? cfg.defaultEngine ?? 'auto'
324
- // 兜底链:首选 defaultEngine(若合理),否则直接微软 edge(免费,无需 key)
325
- const preferred = cfg.defaultEngine !== undefined && cfg.defaultEngine !== 'auto' ? cfg.defaultEngine : 'edge'
326
- const fallbackChain = engine === 'auto'
327
- ? [preferred, 'edge']
328
- : [engine, 'edge']
329
- // [本地改造 2026-08-21] 克隆不再隐式优先:只有 defaultEngine=voiceclone(或显式 provider=voiceclone)
330
- // 才走克隆(synthesizeEngine 的 voiceclone 分支),其余情况走正常引擎链。
331
- for (const candidate of fallbackChain) {
332
- try {
333
- const audio = await synthesizeEngine(candidate, speak, cfg, voiceDesc, overrideVoice)
334
- if (audio !== null) return audio
335
- } catch { /* 尝试下一个 */ }
336
- }
337
- // 最终兜底:微软 edge(免费无需 key),无视 enabled 开关——保证 4 个服务商都未启用时也有声音
338
- try {
339
- const audio = await synthesizeEdgeVoice(speak, cfg.engines.edge)
340
- if (audio !== null) return audio
341
- } catch { /* 忽略 */ }
342
- return null
343
- }
344
-
345
- async function synthesizeEngine(engine, text, cfg, voiceDesc, overrideVoice) {
346
- const e = cfg.engines[engine]
347
- // [本地改造 2026-08-21] 配置存在即启用:设置页已去复选框,enabled 不再拦截;
348
- // 各引擎自身检查必需参数(xiaomi/ali key、local cmd/url、voicedesign 查 key+desc、voiceclone 查 key+样本)。
349
- if (e === undefined) return null
350
- switch (engine) {
351
- case 'edge': return synthesizeEdgeVoice(text, e)
352
- case 'xiaomi': return synthesizeXiaomiVoice(text, e)
353
- case 'voicedesign': return synthesizeXiaomiVoiceDesign(text, e, cfg, voiceDesc, overrideVoice)
354
- case 'voiceclone': return synthesizeXiaomiVoiceClone(text, e, cfg, voiceDesc)
355
- case 'local': return synthesizeLocalVoice(text, e)
356
- case 'ali': return synthesizeAliVoice(text, e)
357
- default: return null
358
- }
359
- }
360
-
361
- // ── edge 微软免费 ──
362
- async function synthesizeEdgeVoice(text, cfg) {
363
- const voice = cfg?.voice ?? 'zh-CN-XiaoxiaoNeural'
364
- const mp3 = await edgeTts(text, voice)
365
- return toMp3(new Uint8Array(mp3), 'audio/mpeg')
366
- }
367
-
368
- // ── xiaomi 小米预置音色(mimo-v2.5-tts)──
369
- async function synthesizeXiaomiVoice(text, cfg) {
370
- const apiKey = cfg?.apiKey ?? ''
371
- if (apiKey === '') return null
372
- const baseUrl = cfg?.baseUrl ?? 'https://api.xiaomimimo.com/v1'
373
- const voice = cfg?.voice ?? '冰糖'
374
- let speak = text
375
- // 唱歌:文本自带 (唱歌) 标签,或明确唱歌意图(唱/歌声)时自动加标签
376
- const hasTag = /^\s*\((唱歌|sing|singing)\)/i.test(speak)
377
- const wantsSing = !hasTag && /(唱(歌|一?首|一段)|歌声回复|用歌声|唱歌回|来一段|唱两句)/i.test(speak)
378
- if (wantsSing) speak = `(唱歌)${speak}`
379
- const messages = []
380
- if (cfg?.context?.trim() !== '') messages.push({ role: 'user', content: cfg.context.trim() })
381
- messages.push({ role: 'assistant', content: speak })
382
- const response = await fetch(`${baseUrl}/chat/completions`, {
383
- method: 'POST',
384
- headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
385
- body: JSON.stringify({
386
- model: 'mimo-v2.5-tts',
387
- messages,
388
- max_tokens: 8192,
389
- audio: { format: 'wav', voice },
390
- }),
391
- })
392
- if (!response.ok) return null
393
- const payload = await response.json()
394
- const data = payload?.choices?.[0]?.message?.audio?.data
395
- if (typeof data !== 'string' || data.length < 100) return null
396
- return toMp3(new Uint8Array(Buffer.from(data, 'base64')), 'audio/wav')
397
- }
398
-
399
- // ── xiaomi 音色设计(mimo-v2.5-tts-voicedesign:user=音色描述,无 voice)──
400
- // [2026-08-22] overrideVoice=true:固定模式(示例/自定义)下 voiceDesc 整体替换底嗓(用户明确要求换声);
401
- // 默认 false:voiceDesc 作为"情绪/风格"叠加在用户设置的底嗓(context)后面——与工具描述一致,不再"非空即覆盖"。
402
- async function synthesizeXiaomiVoiceDesign(text, cfg, globalCfg, voiceDesc, overrideVoice) {
403
- const apiKey = globalCfg.engines.xiaomi.apiKey
404
- // 优先用 AI 动态生成的音色描述(voiceDesc),否则用配置里的默认音色描述
405
- // [2026-08-22] 模式感知兜底:mode=ai 时绝不能回退到用户残留的固定描述(context)——
406
- // 而是按 aiGender/aiAge 生成中性基座(用户没让 AI 写时也稳定),避免"切到 AI 模式却用旧 ASMR 指令"。
407
- const vdMode = cfg?.mode
408
- let desc = (voiceDesc ?? '').trim()
409
- if (vdMode === 'ai') {
410
- // [2026-08-22] AI 模式:身份一律以用户实时配置的锚点为准(锁定项)。
411
- // 修复(XDN 实测): ①"老年感+女孩"分维度拼接→身份自相矛盾(模型选"女孩"→萝莉化),
412
- // 改 ageGenderIdentity 合并成"老年女性/小女孩/少女"等无歧义短语;
413
- // ②"性别/年龄表述忽略"注把 AI 写的"沙哑/苍老/低沉"等最强质感词删了,
414
- // 改为只锁定性别/年龄, 允许情绪与音色质感词保留并强化。
415
- const gKey = cfg?.aiGender === 'male' ? 'male' : cfg?.aiGender === 'female' ? 'female' : ''
416
- const aKey = AI_AGE_LABELS[cfg?.aiAge] !== undefined ? cfg.aiAge : ''
417
- const identity = ageGenderIdentity(aKey, gKey)
418
- const lockG = cfg?.lockGender === true
419
- const lockA = cfg?.lockAge === true
420
- const lockT = cfg?.lockTimbre === true
421
- const gLabel = gKey === 'male' ? '男' : gKey === 'female' ? '女' : ''
422
- const aLabel = AI_AGE_LABELS[aKey] ?? ''
423
- const anchorText = [
424
- lockG ? '性别固定为' + (gLabel !== '' ? gLabel : '每次一致') : '',
425
- lockA ? '年龄感固定为' + (aLabel !== '' ? aLabel : '每次一致') : '',
426
- lockT ? '音色质感保持稳定' : '',
427
- ].filter(Boolean).join('、')
428
- if (identity !== '' || anchorText !== '') {
429
- desc = (identity !== '' ? '一位' + identity + '的声音(身份硬性要求:' + (anchorText !== '' ? anchorText : '按上述身份')
430
- + ';若与其他描述冲突,一律以本身份为准)。' : '')
431
- + (desc !== '' ? '语气/情绪要求:' + desc + '(性别/年龄以身份为准;音色质感与语气情绪按本描述执行——如"沙哑、苍老、低沉、气声"等质感词应保留并强化)。'
432
- : '语气情绪要饱满生动:像真人一样带喜怒哀乐、笑音、撒娇或急切等起伏,禁止平淡。')
433
- } else if (desc === '') {
434
- desc = '语气情绪要饱满生动:像真人一样带喜怒哀乐、笑音、撒娇或急切等起伏,禁止平淡。'
435
- }
436
- } else {
437
- // 固定模式(示例/自定义):底嗓一律用用户设置的 context,voiceDesc 作为情绪/风格叠加在后面(描述与实现一致);
438
- // overrideVoice=true(用户明确要求换一种完全不同的声音)时整体替换。
439
- const base = (cfg?.context?.trim() ?? '')
440
- if (overrideVoice === true && desc !== '') {
441
- desc = desc // 整体替换底嗓
442
- } else {
443
- desc = base + (desc !== '' ? '' + desc : '')
444
- }
445
- }
446
- if (apiKey === '' || desc === '') return null
447
- const baseUrl = globalCfg.engines.xiaomi.baseUrl ?? 'https://api.xiaomimimo.com/v1'
448
- const messages = [
449
- { role: 'user', content: desc },
450
- { role: 'assistant', content: text },
451
- ]
452
- const response = await fetch(`${baseUrl}/chat/completions`, {
453
- method: 'POST',
454
- headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
455
- body: JSON.stringify({
456
- model: 'mimo-v2.5-tts-voicedesign',
457
- messages,
458
- max_tokens: 8192,
459
- audio: { format: 'wav' },
460
- }),
461
- })
462
- if (!response.ok) return null
463
- const payload = await response.json()
464
- const data = payload?.choices?.[0]?.message?.audio?.data
465
- if (typeof data !== 'string' || data.length < 100) return null
466
- return toMp3(new Uint8Array(Buffer.from(data, 'base64')), 'audio/wav')
467
- }
468
-
469
- // ── xiaomi 音色克隆(mimo-v2.5-tts-voiceclone:audio.voice=样本 dataURL,≤10MB)──
470
- // samples: [{id,name,path}] 支持多个克隆音色;兼容旧 samplePath
471
- // voiceDesc 为情感/风格指令(AI 生成,如"委屈撒娇"):优先于 cfg.context,让克隆底嗓带情绪
472
- async function synthesizeXiaomiVoiceClone(text, cfg, globalCfg, voiceDesc) {
473
- const apiKey = globalCfg.engines.xiaomi.apiKey
474
- const samplePath = (Array.isArray(cfg?.samples) && cfg.samples.length > 0 && typeof cfg.samples[0]?.path === 'string' && cfg.samples[0].path !== '')
475
- ? cfg.samples[0].path
476
- : (cfg?.samplePath ?? '')
477
- if (apiKey === '' || samplePath === '') return null
478
- const baseUrl = globalCfg.engines.xiaomi.baseUrl ?? 'https://api.xiaomimimo.com/v1'
479
- let sample
480
- try {
481
- const bytes = await readFile(samplePath)
482
- if (bytes.byteLength > 10 * 1024 * 1024) return null
483
- const suffix = samplePath.toLowerCase().split('.').pop()
484
- const mime = suffix === 'mp3' ? 'audio/mpeg' : suffix === 'wav' ? 'audio/wav' : 'audio/wav'
485
- sample = `data:${mime};base64,${bytes.toString('base64')}`
486
- } catch { return null }
487
- const messages = []
488
- // [本地改造 2026-08-21] 风格指令优先级:voiceDesc(AI 生成)> 样本自带 context(每个克隆音色自己的性格)> 全局 context(兜底)
489
- const firstSample = Array.isArray(cfg?.samples) ? cfg.samples[0] : undefined
490
- const sampleContext = typeof firstSample?.context === 'string' ? firstSample.context.trim() : ''
491
- const styleInstruct = (voiceDesc ?? '').trim() !== ''
492
- ? voiceDesc.trim()
493
- : (sampleContext !== '' ? sampleContext : (cfg?.context?.trim() ?? ''))
494
- if (styleInstruct !== '') messages.push({ role: 'user', content: styleInstruct })
495
- messages.push({ role: 'assistant', content: text })
496
- const response = await fetch(`${baseUrl}/chat/completions`, {
497
- method: 'POST',
498
- headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
499
- body: JSON.stringify({
500
- model: 'mimo-v2.5-tts-voiceclone',
501
- messages,
502
- max_tokens: 8192,
503
- audio: { format: 'wav', voice: sample },
504
- }),
505
- })
506
- if (!response.ok) return null
507
- const payload = await response.json()
508
- const data = payload?.choices?.[0]?.message?.audio?.data
509
- if (typeof data !== 'string' || data.length < 100) return null
510
- return toMp3(new Uint8Array(Buffer.from(data, 'base64')), 'audio/wav')
511
- }
512
-
513
- // [2026-08-22] 解析 Windows 命令行参数(正确处理双引号:引号内空格不拆、剥掉引号)。
514
- // 之前用 command.split(/\s+/) 拆参数,用户填的带引号路径会被整段含引号传给
515
- // execFileSync node "C:\...\local-tts.mjs" 当成相对路径拼上 cwd →
516
- // Cannot find module 'D:\opt\...\"C:\Users\...'。本函数根治该问题。
517
- function splitCommandLine(cmd) {
518
- const args = []
519
- let cur = ''
520
- let inQuote = false
521
- for (let i = 0; i < cmd.length; i++) {
522
- const ch = cmd[i]
523
- if (ch === '"') {
524
- inQuote = !inQuote
525
- } else if (ch === ' ' || ch === '\t') {
526
- if (inQuote) { cur += ch } else if (cur !== '') { args.push(cur); cur = '' }
527
- } else {
528
- cur += ch
529
- }
530
- }
531
- if (cur !== '') args.push(cur)
532
- return args
533
- }
534
-
535
- // ── local 本地 MeloTTS:HTTP 常驻服务优先,CMD 兜底 ──
536
- async function synthesizeLocalVoice(text, cfg) {
537
- const url = cfg?.url?.trim() ?? ''
538
- if (url !== '') {
539
- const response = await fetch(url, {
540
- method: 'POST',
541
- headers: { 'Content-Type': 'application/json' },
542
- body: JSON.stringify({ text }),
543
- timeout: 60_000,
544
- })
545
- if (!response.ok) return null
546
- const body = await response.arrayBuffer()
547
- return toMp3(new Uint8Array(body), 'audio/wav')
548
- }
549
- const command = cfg?.cmd?.trim() ?? ''
550
- if (command === '') return null
551
- const parts = splitCommandLine(command)
552
- const bin = parts[0]
553
- if (bin === undefined) return null
554
- const rest = parts.slice(1)
555
- const audio = execFileSync(bin, [...rest, text], {
556
- windowsHide: true,
557
- encoding: 'buffer',
558
- timeout: 60_000,
559
- })
560
- return toMp3(new Uint8Array(audio), 'audio/mpeg')
561
- }
562
-
563
- // ── ali 阿里 qwen3-tts-flash(dashscope)──
564
- async function synthesizeAliVoice(text, cfg) {
565
- const apiKey = cfg?.apiKey ?? ''
566
- if (apiKey === '') return null
567
- const baseUrl = cfg?.baseUrl ?? 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation'
568
- const voice = cfg?.voice ?? 'Cherry'
569
- const response = await fetch(baseUrl, {
570
- method: 'POST',
571
- headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
572
- body: JSON.stringify({
573
- model: 'qwen3-tts-flash',
574
- input: { text },
575
- parameters: { voice, format: 'wav', language_type: 'zh' },
576
- }),
577
- timeout: 60_000,
578
- })
579
- if (!response.ok) return null
580
- const payload = await response.json()
581
- const audioUrl = payload?.output?.audio?.url
582
- if (typeof audioUrl !== 'string' || audioUrl === '') return null
583
- const audioRes = await fetch(audioUrl, { timeout: 120_000 })
584
- if (!audioRes.ok) return null
585
- const body = await audioRes.arrayBuffer()
586
- return toMp3(new Uint8Array(body), 'audio/wav')
587
- }
588
-
589
- // ──────────────────────────────────────────────────────────────
590
- // ASR 语音识别(三模式:service=本地常驻HTTP / cmd=本地命令 / api=在线API)
591
- // ──────────────────────────────────────────────────────────────
592
- /** base64 音频写入临时 wav,调用本地 sherpa 常驻服务(POST /transcribe {audioPath})或命令。 */
593
- async function transcribeAudio(base64Audio, cfg) {
594
- const asr = cfg?.engines?.asr
595
- if (asr === undefined || asr.enabled === false) return { ok: false, error: 'ASR 未启用' }
596
- if (typeof base64Audio !== 'string' || base64Audio === '') return { ok: false, error: '缺少音频数据' }
597
- const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-asr-${randomUUID()}.raw`)
598
- const tmpWav = join(process.env.TEMP ?? '/tmp', `dsh-asr-${randomUUID()}.wav`)
599
- await writeFile(tmpIn, Buffer.from(base64Audio, 'base64'))
600
- try {
601
- // 统一转成 16kHz 单声道 PCM WAV(录音是 webm/其他容器,sherpa 只认标准 wav)
602
- let wavPath = tmpIn
603
- try {
604
- execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', tmpWav], {
605
- windowsHide: true, stdio: 'ignore', timeout: 30_000,
606
- })
607
- wavPath = tmpWav
608
- // 缓存最近一次录音到 ~/.dsh/last-voice.wav(供"用我刚才那段语音克隆音色"使用)
609
- try {
610
- const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
611
- await mkdir(homeDir, { recursive: true })
612
- await writeFile(join(homeDir, 'last-voice.wav'), await readFile(tmpWav))
613
- } catch { /* 缓存失败不影响识别 */ }
614
- } catch { /* ffmpeg 失败则用原始文件(可能已是 wav) */ }
615
- // 1) 本地常驻服务(nssm: asr,端口 18790;POST /transcribe {audioPath}
616
- if (asr.mode === 'service' && (asr.url ?? '').trim() !== '') {
617
- const baseUrl = asr.url.trim().replace(/\/+$/, '')
618
- const response = await fetch(`${baseUrl}/transcribe`, {
619
- method: 'POST',
620
- headers: { 'Content-Type': 'application/json' },
621
- body: JSON.stringify({ audioPath: wavPath }),
622
- timeout: 60_000,
623
- })
624
- if (!response.ok) return { ok: false, error: `ASR 服务返回 ${response.status}` }
625
- const payload = await response.json().catch(() => ({}))
626
- const text = typeof payload?.text === 'string' ? payload.text.trim() : ''
627
- if (text === '') return { ok: false, error: 'ASR 服务未返回文本' }
628
- return { ok: true, text }
629
- }
630
- // 2) 本地命令(sherpa-onnx-offline.exe,结果输出到 stderr,需合并双流解析)
631
- if (asr.mode === 'cmd' && (asr.cmd ?? '').trim() !== '') {
632
- const parts = splitCommandLine(asr.cmd.trim())
633
- const bin = parts[0]
634
- if (bin === undefined) return { ok: false, error: '命令格式错误' }
635
- const { spawnSync } = await import('node:child_process')
636
- const result = spawnSync(bin, [...parts.slice(1), wavPath], {
637
- windowsHide: true,
638
- encoding: 'utf-8',
639
- timeout: 60_000,
640
- stdio: ['ignore', 'pipe', 'pipe'],
641
- })
642
- // sherpa-onnx 把结果打印到 stderr(stdout 部分版本也有),合并解析
643
- const all = (result.stdout ?? '') + '\n' + (result.stderr ?? '')
644
- const m = all.match(/"text"\s*:\s*"([^"]*)"/)
645
- const text = (m?.[1] ?? '').trim()
646
- if (text === '') return { ok: false, error: '本地命令未输出识别结果' }
647
- return { ok: true, text }
648
- }
649
- // 3) 在线 API(默认小米 mimo-v2.5-asr:OpenAI 兼容 chat/completions + input_audio base64;
650
- // 若 apiBaseUrl openai 则走 Whisper 风格 /audio/transcriptions)
651
- if (asr.mode === 'api' && (asr.apiKey ?? '').trim() !== '') {
652
- const apiKey = asr.apiKey.trim()
653
- const baseUrl = (asr.apiBaseUrl ?? 'https://api.xiaomimimo.com/v1').replace(/\/+$/, '')
654
- const audioBase64 = Buffer.from(base64Audio, 'base64').toString('base64')
655
- if (baseUrl.includes('openai')) {
656
- // Whisper 兼容(multipart file + model)
657
- const form = new FormData()
658
- const blob = new Blob([Buffer.from(audioBase64, 'base64')], { type: 'audio/wav' })
659
- form.append('file', blob, 'audio.wav')
660
- form.append('model', 'whisper-1')
661
- const response = await fetch(`${baseUrl}/audio/transcriptions`, {
662
- method: 'POST',
663
- headers: { Authorization: `Bearer ${apiKey}` },
664
- body: form,
665
- timeout: 60_000,
666
- })
667
- if (!response.ok) return { ok: false, error: `ASR API 返回 ${response.status}` }
668
- const payload = await response.json().catch(() => ({}))
669
- const text = typeof payload?.text === 'string' ? payload.text.trim() : ''
670
- if (text === '') return { ok: false, error: 'ASR API 未返回文本' }
671
- return { ok: true, text }
672
- }
673
- // 小米 mimo-v2.5-asr:chat/completions + input_audio dataURL
674
- const response = await fetch(`${baseUrl}/chat/completions`, {
675
- method: 'POST',
676
- headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
677
- body: JSON.stringify({
678
- model: 'mimo-v2.5-asr',
679
- messages: [
680
- {
681
- role: 'user',
682
- content: [
683
- { type: 'input_audio', input_audio: { data: `data:audio/wav;base64,${audioBase64}` } },
684
- ],
685
- },
686
- ],
687
- extra_body: { asr_options: { language: 'auto' } },
688
- }),
689
- timeout: 120_000,
690
- })
691
- if (!response.ok) return { ok: false, error: `小米 ASR 返回 ${response.status}` }
692
- const payload = await response.json().catch(() => ({}))
693
- const text = typeof payload?.choices?.[0]?.message?.content === 'string'
694
- ? payload.choices[0].message.content.trim()
695
- : ''
696
- if (text === '') return { ok: false, error: '小米 ASR 未返回文本' }
697
- return { ok: true, text }
698
- }
699
- return { ok: false, error: 'ASR 未配置(服务地址/命令/API Key 三选一)' }
700
- } catch (error) {
701
- return { ok: false, error: error instanceof Error ? error.message : 'ASR 转写失败' }
702
- } finally {
703
- await unlink(tmpWav).catch(() => {})
704
- await unlink(tmpIn).catch(() => {})
705
- }
706
- }
707
-
708
- // ──────────────────────────────────────────────────────────────
709
- // 音频工具
710
- // ──────────────────────────────────────────────────────────────
711
- async function toMp3(data, declared) {
712
- const isMp3 = data.length > 2 && data[0] === 0xFF && ((data[1] ?? 0) & 0xE0) === 0xE0
713
- let finalData = data
714
- let mediaType = declared
715
- if (!isMp3) {
716
- const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-tts-in-${randomUUID()}.wav`)
717
- const mp3Path = join(process.env.TEMP ?? '/tmp', `dsh-tts-${randomUUID()}.mp3`)
718
- await writeFile(tmpIn, data)
719
- try {
720
- execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-c:a', 'libmp3lame', '-b:a', '128k', mp3Path], {
721
- windowsHide: true, stdio: 'ignore', timeout: 30_000,
722
- })
723
- finalData = new Uint8Array(await readFile(mp3Path))
724
- mediaType = 'audio/mpeg'
725
- } catch {
726
- // 转码失败保留原容器(部分浏览器仍可播)。
727
- } finally {
728
- await unlink(tmpIn).catch(() => {})
729
- await unlink(mp3Path).catch(() => {})
730
- }
731
- }
732
- const durationMs = estimateAudioDurationMs(finalData)
733
- return {
734
- mediaType,
735
- data: finalData,
736
- ...(durationMs === undefined ? {} : { durationMs }),
737
- }
738
- }
739
-
740
- function looksLikeOgg(data) {
741
- return data.length >= 4
742
- && data[0] === 0x4F && data[1] === 0x67 && data[2] === 0x67 && data[3] === 0x53
743
- }
744
-
745
- function stripMarkdown(text) {
746
- return text
747
- .replace(/```[\s\S]*?```/g, ' ')
748
- .replace(/`([^`]+)`/g, '$1')
749
- .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
750
- .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
751
- .replace(/^#{1,6}\s*/gm, '')
752
- .replace(/^>+\s*/gm, '')
753
- .replace(/^\s*[-*+]\s+/gm, '')
754
- .replace(/^\s*\d+[.)]\s+/gm, '')
755
- .replace(/^\s*\|?[\s:|-]+\|?\s*$/gm, '')
756
- .replace(/^[-*_]{3,}\s*$/gm, '')
757
- .replace(/\|/g, ' ')
758
- .replace(/\*\*([^*]+)\*\*/g, '$1')
759
- .replace(/\*([^*]+)\*/g, '$1')
760
- .replace(/__([^_]+)__/g, '$1')
761
- .replace(/_([^_]+)_/g, '$1')
762
- .replace(/~~([^~]+)~~/g, '$1')
763
- .replace(/^\s*[-*_]\s*$/gm, '')
764
- .replace(/\s*\n\s*/g, ' ')
765
- .replace(/\s{2,}/g, ' ')
766
- .trim()
767
- }
768
-
769
- function estimateAudioDurationMs(data) {
770
- if (looksLikeOgg(data)) {
771
- const kbps = 48
772
- return Math.round(data.length / (kbps * 1000 / 8) * 1000)
773
- }
774
- let offset = 0
775
- if (data.length >= 10 && (data[0] ?? 0) === 0x49 && (data[1] ?? 0) === 0x44 && (data[2] ?? 0) === 0x33
776
- && ((data[3] ?? 0) & 0xFF) < 0xFF && ((data[4] ?? 0) & 0xFF) < 0xFF) {
777
- const size = (((data[6] ?? 0) & 0x7F) << 21) | (((data[7] ?? 0) & 0x7F) << 14)
778
- | (((data[8] ?? 0) & 0x7F) << 7) | ((data[9] ?? 0) & 0x7F)
779
- offset = 10 + size
780
- }
781
- while (offset + 4 <= data.length) {
782
- const sync = ((data[offset] ?? 0) << 8) | (data[offset + 1] ?? 0)
783
- if ((sync & 0xFFE0) === 0xFFE0) {
784
- const bitrateIndex = ((data[offset + 2] ?? 0) >>> 4) & 0x0F
785
- const sampleRateIndex = ((data[offset + 2] ?? 0) >>> 2) & 0x03
786
- if (bitrateIndex === 0 || bitrateIndex === 15 || sampleRateIndex === 3) return undefined
787
- const bitrates = [32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]
788
- const kbps = bitrates[bitrateIndex - 1] ?? 128
789
- return Math.round((data.length - offset) / (kbps * 1000 / 8) * 1000)
790
- }
791
- offset += 1
792
- }
793
- return undefined
794
- }
795
-
796
- // ──────────────────────────────────────────────────────────────
797
- // 自动语音回复辅助
798
- // ──────────────────────────────────────────────────────────────
799
- /** 用户文本是否明确要求语音回复;返回要用的 TTS provider,否则 null。 */
800
- function voiceRequestProvider(text) {
801
- if (!/(用语音回|回个语音|发个语音|发语音|用语音说|语音回我|语音告诉我|念给我|语音播报|用小米|用微软|小米语音|微软语音|xiaomi|edge语音|语音回复我)/i.test(text)) return null
802
- if (/小米|xiaomi/i.test(text)) return 'xiaomi'
803
- if (/微软|edge/i.test(text)) return 'edge'
804
- if (/阿里|ali/i.test(text)) return 'ali'
805
- return 'auto'
806
- }
807
-
808
- /** 判断一行是否像代码/噪声,不该被念出来(中文口语基本不会命中这些模式)。 */
809
- function looksLikeCodeLine(t) {
810
- if (/[=;{}<>$|]/.test(t)) return true // 赋值/分号/花括号/尖括号/管道/美元
811
- if (/=>|::/.test(t)) return true // 箭头函数/作用域
812
- if (/\b[a-z_]\w{2,}\s*\(/.test(t)) return true // 函数调用 foo(
813
- if (/\.\w+(\s*\(|\s*=)/.test(t)) return true // 方法链 obj.method(
814
- if (/^\s*(const|let|var|function|def|class|import|export|return|if|for|while|public|private|async|await|SELECT|INSERT|UPDATE|FROM|WHERE|npm|npx|pip|cd|ls|git|sudo|curl|wget|docker|kubectl|python|node|tsx|pnpm|yarn|bun|cargo)\b/i.test(t)) return true
815
- if (/[\\/][\w.-]+\.\w{1,5}/.test(t)) return true // 文件路径 c:\x.js / /a/b.ts
816
- if (/"[^"]*"\s*[:=]/.test(t)) return true // "key": 或 "key" =
817
- if (/\b0x[0-9a-f]+/i.test(t)) return true // 十六进制
818
- if (/[a-z][A-Z]\w*\s*\(/.test(t)) return true // camelCase(
819
- return false
820
- }
821
-
822
- /** 从助手文本提取适合语音念的口语部分(去代码/URL/Markdown,取前 2 句,最多约 200 字)。 */
823
- function extractSpeakable(text) {
824
- const cleaned = text
825
- .replace(/```[\s\S]*?```/g, ' ') // 整段代码块
826
- .replace(/`[^`]*`/g, ' ') // 行内代码
827
- .replace(/https?:\/\/\S+/g, ' ') // 链接
828
- .replace(/[#>*|~-]\s*/g, ' ') // Markdown 符号
829
- const lines = cleaned.split('\n').filter((line) => {
830
- const t = line.trim()
831
- if (t === '') return false
832
- if (looksLikeCodeLine(t)) return false
833
- if (/^[\d\s.,%:/-]+$/.test(t)) return false // 纯数字/标点
834
- return true
835
- })
836
- const prose = lines.join(' ').replace(/\s+/g, ' ').trim()
837
- if (prose === '') return ''
838
- const sentences = prose.match(/[^。!?.!?]+[。!?.!?]?/g) ?? [prose]
839
- let speak = ''
840
- for (const sentence of sentences.slice(0, 2)) {
841
- if ((speak + sentence).length > 200) break
842
- speak += sentence
843
- }
844
- return speak.trim()
845
- }
846
-
847
- // ──────────────────────────────────────────────────────────────
848
- // HTTP 工具
849
- // ──────────────────────────────────────────────────────────────
850
- function sendJson(res, status, body) {
851
- res.statusCode = status
852
- res.setHeader('Content-Type', 'application/json; charset=utf-8')
853
- res.end(JSON.stringify(body))
854
- }
855
-
856
- async function readJsonBody(req, maxBytes) {
857
- const chunks = []
858
- let total = 0
859
- for await (const chunk of req) {
860
- total += chunk.length
861
- if (total > (maxBytes ?? 1024 * 1024)) throw new Error('body too large')
862
- chunks.push(chunk)
863
- }
864
- return JSON.parse(Buffer.concat(chunks).toString('utf8'))
865
- }
866
-
867
- // ──────────────────────────────────────────────────────────────
868
- // 插件入口
869
- // ──────────────────────────────────────────────────────────────
870
- async function apply(ctx) {
871
- ctx.effect(() => {
872
- const disposers = []
873
-
874
- // 0) 设置页配置路由
875
- if (typeof ctx.webServer?.register === 'function') {
876
- disposers.push(ctx.webServer.register({
877
- kind: 'prefix',
878
- path: '/voice-config',
879
- handler: async (req, res) => {
880
- const url = new URL(req.url ?? '/', 'http://x')
881
- try {
882
- if (url.pathname === '/voice-config' && req.method === 'GET') {
883
- const cfg = await loadVoiceConfig()
884
- return sendJson(res, 200, { ok: true, config: cfg })
885
- }
886
- if (url.pathname === '/voice-config' && req.method === 'POST') {
887
- const body = await readJsonBody(req, 512 * 1024)
888
- const cfg = await saveVoiceConfig(body?.config ?? {})
889
- return sendJson(res, 200, { ok: true, config: cfg })
890
- }
891
- if (url.pathname === '/voice-config/engines' && req.method === 'GET') {
892
- const cfg = await loadVoiceConfig()
893
- return sendJson(res, 200, {
894
- ok: true,
895
- engines: {
896
- xiaomiVoices: ['冰糖', '茉莉', '苏打', '白桦', 'Mia', 'Chloe', 'Milo', 'Dean'],
897
- edgeVoices: [
898
- 'zh-CN-XiaoxiaoNeural', 'zh-CN-XiaoyiNeural', 'zh-CN-YunxiNeural',
899
- 'zh-CN-YunyangNeural', 'zh-CN-YunjianNeural', 'zh-CN-XiaochenNeural',
900
- 'zh-CN-XiaohanNeural', 'zh-CN-XiaomengNeural', 'zh-CN-XiaomoNeural',
901
- 'zh-CN-XiaoqiuNeural', 'zh-CN-XiaoruiNeural', 'zh-CN-XiaoshuangNeural',
902
- 'zh-CN-XiaoxuanNeural', 'zh-CN-XiaoyanNeural', 'zh-CN-XiaoyouNeural',
903
- 'zh-CN-XiaozhenNeural', 'zh-CN-YunfengNeural', 'zh-CN-YunhaoNeural',
904
- 'zh-CN-YunjieNeural', 'zh-CN-YunxiaNeural', 'zh-TW-HsiaoChenNeural',
905
- 'zh-TW-HsiaoYuNeural', 'zh-TW-YunJheNeural',
906
- ],
907
- aliVoices: ['Cherry', 'Sensibility', 'Starter', 'Luna', 'Ethan', 'Cozy', 'Longxiaochun', 'Lusheng', 'Jingyuan', 'Zhibo', 'Conductor', 'Narrator', 'Storyteller', 'Jianzhi', 'Fangzhou', 'Xiaobei', 'Xiaolan', 'Xiaomei', 'Xiaomeng', 'Xiaomo', 'Xiaoxin', 'Xiaoyu'],
908
- // 哪些 key 当前来自环境变量(设置页显示"已填写"提示)
909
- envKeys: {
910
- xiaomi: typeof process.env.TTS_XIAOMI_KEY === 'string' && process.env.TTS_XIAOMI_KEY !== '',
911
- ali: typeof process.env.TTS_ALI_KEY === 'string' && process.env.TTS_ALI_KEY !== '',
912
- },
913
- },
914
- })
915
- }
916
- // 音色试听:POST { engine, voice?, text?, context?, samplePath? } → 合成并返回音频 base64 + mediaType
917
- if (url.pathname === '/voice-config/preview' && req.method === 'POST') {
918
- const body = await readJsonBody(req, 64 * 1024)
919
- const engine = typeof body?.engine === 'string' ? body.engine : 'edge'
920
- const voice = typeof body?.voice === 'string' ? body.voice : undefined
921
- const text = typeof body?.text === 'string' && body.text.trim() !== '' ? body.text.trim() : '你好,这是一段语音试听。'
922
- const context = typeof body?.context === 'string' ? body.context : undefined
923
- const samplePath = typeof body?.samplePath === 'string' ? body.samplePath : undefined
924
- const cloneContext = typeof body?.cloneContext === 'string' ? body.cloneContext : undefined // [2026-08-22] 克隆试听时作为样本自带指令
925
- const cfg = await loadVoiceConfig()
926
- // 临时覆盖音色/情绪/样本试听(不改持久化配置)
927
- if (voice !== undefined && cfg.engines[engine] !== undefined && engine !== 'voicedesign' && engine !== 'voiceclone') {
928
- cfg.engines[engine].voice = voice
929
- }
930
- if (context !== undefined) {
931
- if (engine === 'voicedesign') cfg.engines.voicedesign.context = context
932
- else if (engine === 'xiaomi') cfg.engines.xiaomi.context = context
933
- }
934
- // voiceclone 试听:用指定样本临时替换 samples(避免 samples[0] 优先导致试听错样本);
935
- // [2026-08-22] cloneContext 作为样本自带指令传入,合成时能带出音色性格(如小团团沙雕可爱腔)
936
- if (engine === 'voiceclone') {
937
- const sp = (samplePath !== undefined && samplePath !== '') ? samplePath : (cfg.engines.voiceclone.samples[0]?.path ?? '')
938
- cfg.engines.voiceclone.samples = [{ id: '__preview__', name: '__preview__', path: sp, context: cloneContext ?? '' }]
939
- }
940
- // local 试听:body.cmd / body.url 临时覆盖(用户未保存前也能试听)
941
- if (engine === 'local') {
942
- if (typeof body?.cmd === 'string') cfg.engines.local.cmd = body.cmd
943
- if (typeof body?.url === 'string') cfg.engines.local.url = body.url
944
- }
945
- let audio = null
946
- if (engine === 'edge' || engine === 'xiaomi' || engine === 'local' || engine === 'ali') {
947
- audio = await synthesizeEngine(engine, text, cfg)
948
- } else if (engine === 'voicedesign') {
949
- audio = await synthesizeXiaomiVoiceDesign(text, cfg.engines.voicedesign, cfg)
950
- } else if (engine === 'voiceclone') {
951
- audio = await synthesizeXiaomiVoiceClone(text, cfg.engines.voiceclone, cfg)
952
- }
953
- if (audio === null) return sendJson(res, 400, { ok: false, error: `合成失败:${engine} 未启用或缺少凭据` })
954
- return sendJson(res, 200, {
955
- ok: true,
956
- mediaType: audio.mediaType,
957
- data: Buffer.from(audio.data).toString('base64'),
958
- durationMs: audio.durationMs,
959
- })
960
- }
961
- // [本地改造 2026-08-21] 克隆样本添加:POST { name, audioBase64, mediaType }
962
- // → 校验(≤10MB、mp3/wav)→ 存 ~/.dsh/voiceclone-samples/ → 写入 voiceclone.samples
963
- if (url.pathname === '/voice-config/voice-clone/add' && req.method === 'POST') {
964
- const body = await readJsonBody(req, 16 * 1024 * 1024)
965
- const b64 = typeof body?.audioBase64 === 'string'
966
- ? body.audioBase64.replace(/^data:[^;]*;base64,/, '')
967
- : ''
968
- if (b64 === '') return sendJson(res, 400, { ok: false, error: '缺少音频数据' })
969
- const bytes = Buffer.from(b64, 'base64')
970
- if (bytes.byteLength === 0) return sendJson(res, 400, { ok: false, error: '音频为空' })
971
- if (bytes.byteLength > 10 * 1024 * 1024) {
972
- return sendJson(res, 400, { ok: false, error: '音频需在 10MB 以内(官方限制;参考语音建议 15-60 秒,越长克隆越准)' })
973
- }
974
- const mediaType = typeof body?.mediaType === 'string' ? body.mediaType : 'audio/wav'
975
- const isMp3 = /mp3|mpeg/i.test(mediaType)
976
- const isWav = /wav|wave/i.test(mediaType)
977
- let finalBytes = bytes
978
- let finalSuffix = isMp3 ? 'mp3' : 'wav'
979
- // [本地改造 2026-08-21] 非 mp3/wav(webm/ogg/mp4 等)用 ffmpeg 转 16k 单声道 wav,
980
- // 保证克隆样本可被 MiMo 读取(否则存成 .wav 实为其它容器,克隆会失败)
981
- if (!isMp3 && !isWav) {
982
- const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-clone-in-${randomUUID()}`)
983
- const tmpWav = `${tmpIn}.wav`
984
- try {
985
- await writeFile(tmpIn, bytes)
986
- execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', tmpWav], {
987
- windowsHide: true, stdio: 'ignore', timeout: 60_000,
988
- })
989
- finalBytes = await readFile(tmpWav)
990
- finalSuffix = 'wav'
991
- } catch { /* 转码失败保留原始字节(后缀按 wav 存) */ }
992
- finally {
993
- await unlink(tmpIn).catch(() => {})
994
- await unlink(tmpWav).catch(() => {})
995
- }
996
- }
997
- const name = (typeof body?.name === 'string' && body.name.trim() !== '')
998
- ? body.name.trim()
999
- : `克隆音色-${Date.now()}`
1000
- const dir = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'voiceclone-samples')
1001
- await mkdir(dir, { recursive: true })
1002
- const id = randomUUID()
1003
- const samplePath = join(dir, `${id}.${finalSuffix}`)
1004
- await writeFile(samplePath, finalBytes)
1005
- const cfg = await loadVoiceConfig()
1006
- const samples = Array.isArray(cfg.engines?.voiceclone?.samples)
1007
- ? [...cfg.engines.voiceclone.samples]
1008
- : []
1009
- samples.push({
1010
- id, name, path: samplePath,
1011
- context: typeof body?.context === 'string' ? body.context : '', // [2026-08-22] 该音色默认沟通指令
1012
- previewText: typeof body?.previewText === 'string' ? body.previewText : '', // [2026-08-22] 该音色试听文本
1013
- })
1014
- const next = await saveVoiceConfig({
1015
- ...cfg,
1016
- engines: {
1017
- ...cfg.engines,
1018
- voiceclone: { ...cfg.engines.voiceclone, samples },
1019
- },
1020
- })
1021
- return sendJson(res, 200, { ok: true, sample: { id, name, path: samplePath }, config: next })
1022
- }
1023
- // [本地改造 2026-08-21] 克隆原音频试听:POST { path } → 读样本文件返回音频
1024
- // (仅允许 voiceclone-samples 目录内的文件,防任意路径读取)
1025
- if (url.pathname === '/voice-config/voice-clone/source' && req.method === 'POST') {
1026
- const body = await readJsonBody(req, 64 * 1024)
1027
- const rawPath = typeof body?.path === 'string' ? body.path : ''
1028
- if (rawPath === '') return sendJson(res, 400, { ok: false, error: '缺少 path' })
1029
- // 白名单校验:path 必须是 voiceclone.samples 里登记的样本文件
1030
- const cfgNow = await loadVoiceConfig()
1031
- const target = resolve(rawPath)
1032
- const known = (cfgNow.engines?.voiceclone?.samples ?? [])
1033
- .some((s) => typeof s?.path === 'string' && resolve(s.path) === target)
1034
- if (!known) {
1035
- return sendJson(res, 403, { ok: false, error: 'path 不是已登记的克隆样本' })
1036
- }
1037
- try {
1038
- const bytes = await readFile(target)
1039
- const suffix = target.toLowerCase().split('.').pop()
1040
- const mediaType = suffix === 'mp3' ? 'audio/mpeg' : 'audio/wav'
1041
- return sendJson(res, 200, { ok: true, mediaType, data: bytes.toString('base64') })
1042
- } catch {
1043
- return sendJson(res, 404, { ok: false, error: '样本文件不存在' })
1044
- }
1045
- }
1046
- // [2026-08-22] 克隆合成试听录音(预生成静态文件,免联网):GET ?id=<sampleId> → DSH_HOME/voiceclone-samples/<id>-preview.mp3
1047
- // 与 VoiceDesign 官方示例同思路:录音打进包内/落地本地,播放不再每次调官方合成
1048
- if (url.pathname === '/voice-config/voice-clone/preview-sample' && req.method === 'GET') {
1049
- const id = url.searchParams.get('id') ?? ''
1050
- if (!/^[0-9a-fA-F-]{36}$/.test(id)) return sendJson(res, 400, { ok: false, error: 'invalid id' })
1051
- const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1052
- const dir = resolve(join(homeDir, 'voiceclone-samples'))
1053
- const target = resolve(join(dir, id + '-preview.mp3'))
1054
- if (!target.toLowerCase().startsWith(dir.toLowerCase() + sep)) {
1055
- return sendJson(res, 403, { ok: false, error: 'forbidden' })
1056
- }
1057
- try {
1058
- const bytes = await readFile(target)
1059
- return sendJson(res, 200, { ok: true, mediaType: 'audio/mpeg', data: bytes.toString('base64') })
1060
- } catch {
1061
- return sendJson(res, 404, { ok: false, error: '尚未生成试听录音' })
1062
- }
1063
- }
1064
- return sendJson(res, 404, { ok: false, error: 'not found' })
1065
- } catch (error) {
1066
- return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
1067
- }
1068
- },
1069
- }))
1070
- // ASR 配置与转写路由(独立前缀,与 /voice-config 分开注册)
1071
- disposers.push(ctx.webServer.register({
1072
- kind: 'prefix',
1073
- path: '/asr',
1074
- handler: async (req, res) => {
1075
- const url = new URL(req.url ?? '/', 'http://x')
1076
- try {
1077
- if (url.pathname === '/asr/config') {
1078
- const cfg = await loadVoiceConfig()
1079
- if (req.method === 'GET') return sendJson(res, 200, { ok: true, asr: cfg.engines.asr })
1080
- if (req.method === 'POST') {
1081
- const body = await readJsonBody(req, 64 * 1024)
1082
- const saved = await saveVoiceConfig({ engines: { asr: body?.asr ?? {} } })
1083
- return sendJson(res, 200, { ok: true, asr: saved.engines.asr })
1084
- }
1085
- }
1086
- if (url.pathname === '/asr/transcribe' && req.method === 'POST') {
1087
- const body = await readJsonBody(req, 32 * 1024 * 1024)
1088
- const cfg = await loadVoiceConfig()
1089
- const result = await transcribeAudio(body?.audioBase64, cfg)
1090
- return sendJson(res, result.ok ? 200 : 400, { ok: result.ok, text: result.text, error: result.error })
1091
- }
1092
- // 示例音频:首次用 edge TTS 合成并缓存到 ~/.dsh/asr-sample.wav,之后直接读文件(不再临时生成)
1093
- if (url.pathname === '/asr/sample' && req.method === 'GET') {
1094
- const samplePath = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'asr-sample.wav')
1095
- let wavBytes = null
1096
- try {
1097
- wavBytes = await readFile(samplePath)
1098
- } catch { /* 首次无缓存 */ }
1099
- if (wavBytes === null) {
1100
- const text = '你好,这是一段语音识别测试音频。你可以点击播放试听,也可以直接识别这段音频。'
1101
- const mp3 = await edgeTts(text, 'zh-CN-XiaoxiaoNeural').catch(() => null)
1102
- if (mp3 === null) return sendJson(res, 400, { ok: false, error: '示例音频合成失败' })
1103
- const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-asr-sample-${randomUUID()}.mp3`)
1104
- const tmpWav = join(process.env.TEMP ?? '/tmp', `dsh-asr-sample-${randomUUID()}.wav`)
1105
- await writeFile(tmpIn, Buffer.from(mp3))
1106
- try {
1107
- execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', tmpWav], {
1108
- windowsHide: true, stdio: 'ignore', timeout: 30_000,
1109
- })
1110
- wavBytes = await readFile(tmpWav)
1111
- await mkdir(join(samplePath, '..'), { recursive: true })
1112
- await writeFile(samplePath, wavBytes)
1113
- } catch {
1114
- return sendJson(res, 400, { ok: false, error: '示例音频转码失败' })
1115
- } finally {
1116
- await unlink(tmpIn).catch(() => {})
1117
- await unlink(tmpWav).catch(() => {})
1118
- }
1119
- }
1120
- return sendJson(res, 200, {
1121
- ok: true,
1122
- mediaType: 'audio/wav',
1123
- data: Buffer.from(wavBytes).toString('base64'),
1124
- })
1125
- }
1126
- // 探测本机 ASR:sherpa exe / 模型 / 18790 服务 / ffmpeg,返回可自动填写的配置
1127
- if (url.pathname === '/asr/detect' && req.method === 'GET') {
1128
- // 安装目录 = 插件包根目录下的 sherpa-onnx/(脚本 install-asr.ps1 同规则推导)
1129
- const here = join(fileURLToPath(import.meta.url), '..') // .../lib
1130
- const pluginRoot = join(here, '..') // .../(包根)
1131
- const sherpaDir = join(pluginRoot, 'sherpa-onnx')
1132
- const candidates = [
1133
- join(sherpaDir, 'bin', 'sherpa-onnx-offline.exe'),
1134
- 'C:\\D\\opt\\sherpa-onnx\\bin\\sherpa-onnx-offline.exe', // 兼容历史安装
1135
- ]
1136
- const modelDirs = [
1137
- join(sherpaDir, 'models', 'sensevoice-int8'),
1138
- 'C:\\D\\opt\\sherpa-onnx\\models\\sensevoice-int8', // 兼容历史安装
1139
- ]
1140
- let exe = null
1141
- for (const c of candidates) { try { await readFile(c); exe = c; break } catch { /* 继续 */ } }
1142
- let modelDir = null
1143
- for (const m of modelDirs) { try { await readFile(join(m, 'model.int8.onnx')); modelDir = m; break } catch { /* 继续 */ } }
1144
- let ffmpegOk = false
1145
- try { execFileSync('ffmpeg', ['-version'], { windowsHide: true, stdio: 'ignore', timeout: 5000 }); ffmpegOk = true } catch { /* 无 */ }
1146
- // 探测 18790 服务
1147
- let serviceOk = false
1148
- try {
1149
- const r = await fetch('http://127.0.0.1:18790/health', { timeout: 3000 })
1150
- serviceOk = r.ok
1151
- } catch { /* */ }
1152
- const cmd = exe !== null && modelDir !== null
1153
- ? `${exe} --tokens=${modelDir}\\tokens.txt --sense-voice-model=${modelDir}\\model.int8.onnx --num-threads=4`
1154
- : ''
1155
- return sendJson(res, 200, {
1156
- ok: true,
1157
- detected: {
1158
- exe, modelDir, ffmpegOk, serviceOk,
1159
- url: serviceOk ? 'http://127.0.0.1:18790' : '',
1160
- cmd,
1161
- installDir: sherpaDir,
1162
- },
1163
- })
1164
- }
1165
- // 返回一键安装命令(用户复制到管理员 PowerShell 运行)
1166
- if (url.pathname === '/asr/install-script' && req.method === 'GET') {
1167
- const here = join(fileURLToPath(import.meta.url), '..') // .../lib
1168
- const scriptPath = join(here, '..', 'scripts', 'install-asr.ps1') // .../scripts
1169
- try {
1170
- await readFile(scriptPath, 'utf8') // 确认脚本存在
1171
- const installDir = join(here, '..', 'sherpa-onnx')
1172
- return sendJson(res, 200, {
1173
- ok: true,
1174
- scriptPath,
1175
- installDir,
1176
- command: `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`,
1177
- })
1178
- } catch {
1179
- return sendJson(res, 404, { ok: false, error: '安装脚本不存在' })
1180
- }
1181
- }
1182
- // VoiceDesign 官方示例音频:[0.3.4] 优先读插件包自带素材(assets/,mp3 下载即用),
1183
- // 包内缺失才回退到"小米模型在线生成 + 缓存到 ~/.dsh/voice-design-samples/ 的 wav"。
1184
- if (url.pathname === '/asr/voice-design-samples' && req.method === 'GET') {
1185
- const cfg = await loadVoiceConfig()
1186
- const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1187
- const sampleDir = join(homeDir, 'voice-design-samples')
1188
- const samples = [
1189
- {
1190
- key: 'asmr', title: 'ASMR 双耳女声',
1191
- instruct: '年轻的女性声音,近距离的聆听效果,带有双耳刺激的ASMR感。可以听到她的呼吸声、轻微的吞咽声,以及轻柔的自然唇音。她的说话速度非常慢,营造出一种极度放松且沉浸式的体验。',
1192
- text: '嘘……放松点,再靠近一点吧。我现在就在你身边。慢慢、轻柔地呼吸,让思绪随着水流轻轻流淌,就像沉浸在温暖的水中一样。',
1193
- },
1194
- {
1195
- key: 'docu', title: '纪录片旁白',
1196
- instruct: '一位中年男性,说标准普通话,嗓音低沉有磁性,带有轻微的沙哑质感,像纪录片旁白解说员,沉稳而有感染力。',
1197
- text: '当最后一缕阳光消失在地平线之下,这片沉睡了亿万年的大地开始显露它真正的面貌。每一块岩石都记录着时间的流逝,每一阵风都在诉说着古老的故事。',
1198
- },
1199
- {
1200
- key: 'elder', title: '年迈老先生旁白',
1201
- instruct: '一位年迈的老先生,说带北方口音的普通话,语速缓慢而沉稳,嗓音略带沙哑和沧桑感,仿佛一位饱经风霜的老爷爷在讲故事,充满岁月的智慧。',
1202
- text: '我这辈子啊,走南闯北六十多年。见过最热闹的集市,也见过最安静的戈壁。到头来才明白一个道理,不在于走了多远的路,在于记住了多少风景。年轻人,别光顾着赶路,偶尔也停下来看看天。',
1203
- },
1204
- ]
1205
- const results = []
1206
- for (const s of samples) {
1207
- // 1) 包内自带 mp3(首选,下载即用)
1208
- let bytes = null
1209
- try { bytes = await readFile(join(ASSETS_DIR, 'voice-design-samples', `${s.key}.mp3`)) } catch { /* 包内无 → 回退 */ }
1210
- let mediaType = 'audio/mpeg'
1211
- if (bytes === null) {
1212
- // 2) 缓存 wav(在线生成过)
1213
- const wavPath = join(sampleDir, `${s.key}.wav`)
1214
- try { bytes = await readFile(wavPath) } catch { /* 继续回退 */ }
1215
- mediaType = 'audio/wav'
1216
- }
1217
- if (bytes === null) {
1218
- // 3) 在线生成(key 缺失会失败,示例跳过)
1219
- try {
1220
- const syn = await synthesizeXiaomiVoiceDesign(s.text, { context: s.instruct }, cfg)
1221
- if (syn === null) throw new Error('voicedesign synth failed')
1222
- bytes = Buffer.from(syn.data)
1223
- mediaType = 'audio/wav'
1224
- await mkdir(sampleDir, { recursive: true })
1225
- await writeFile(join(sampleDir, `${s.key}.wav`), bytes)
1226
- } catch { /* 模型生成失败 跳过 */ }
1227
- }
1228
- if (bytes !== null) {
1229
- results.push({ key: s.key, title: s.title, mediaType, data: bytes.toString('base64') })
1230
- }
1231
- }
1232
- return sendJson(res, 200, { ok: results.length > 0, samples: results })
1233
- }
1234
- return sendJson(res, 404, { ok: false, error: 'not found' })
1235
- } catch (error) {
1236
- return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
1237
- }
1238
- },
1239
- }))
1240
- // [2026-08-21] 本地 TTS 配置与安装脚本路由(独立 prefix,勿放进 /asr)
1241
- disposers.push(ctx.webServer.register({
1242
- kind: 'prefix',
1243
- path: '/tts',
1244
- handler: async (req, res) => {
1245
- const url = new URL(req.url ?? '/', 'http://x')
1246
- try {
1247
- if (url.pathname === '/tts/install-script' && req.method === 'GET') {
1248
- const here = join(fileURLToPath(import.meta.url), '..') // .../lib
1249
- const scriptPath = join(here, '..', 'scripts', 'install-local-tts.ps1') // .../scripts
1250
- try {
1251
- await readFile(scriptPath, 'utf8') // 确认脚本存在
1252
- return sendJson(res, 200, {
1253
- ok: true,
1254
- scriptPath,
1255
- installDir: join(here, '..', 'sherpa-onnx'),
1256
- command: `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`,
1257
- })
1258
- } catch {
1259
- return sendJson(res, 404, { ok: false, error: '安装脚本不存在' })
1260
- }
1261
- }
1262
- return sendJson(res, 404, { ok: false, error: 'not found' })
1263
- } catch (error) {
1264
- return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
1265
- }
1266
- },
1267
- }))
1268
-
1269
- // [2026-08-21] 语音气泡(聊天界面 DOM 注入)配套:录音文件存取 + 能力检测。
1270
- // 独立 prefix:聊天界面语音条的前端注入需要能播放"用户刚才那段语音"的音频 URL。
1271
- disposers.push(ctx.webServer.register({
1272
- kind: 'prefix',
1273
- path: '/voice',
1274
- handler: async (req, res) => {
1275
- const url = new URL(req.url ?? '/', 'http://x')
1276
- try {
1277
- // 能力检测:插件自带能力 vs dsh 原生契约支持(用于设置页状态面板)
1278
- if (url.pathname === '/voice/capabilities' && req.method === 'GET') {
1279
- return sendJson(res, 200, {
1280
- ok: true,
1281
- capabilities: {
1282
- voiceInput: true, // 录音+ASR+发送:插件自带
1283
- voiceBubble: 'plugin-dom', // 聊天语音气泡:插件 DOM 注入(安装即用)
1284
- voiceContentContract: await detectVoiceContractSupport(), // dsh 原生契约是否支持 voice content
1285
- },
1286
- })
1287
- }
1288
- // 保存录音(语音气泡数据源):DSH_HOME/voice-outbox/<voiceId>.<ext>
1289
- if (url.pathname === '/voice/outbox/save' && req.method === 'POST') {
1290
- const body = await readJsonBody(req)
1291
- const b64 = typeof body?.audioBase64 === 'string' ? body.audioBase64 : ''
1292
- const mediaType = typeof body?.mediaType === 'string' ? body.mediaType : 'audio/webm'
1293
- if (b64 === '') return sendJson(res, 400, { ok: false, error: '缺少音频数据' })
1294
- const ext = VOICE_OUTBOX_EXT[mediaType] ?? 'webm'
1295
- const voiceId = randomUUID()
1296
- const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1297
- const dir = join(homeDir, 'voice-outbox')
1298
- await mkdir(dir, { recursive: true })
1299
- await writeFile(join(dir, `${voiceId}.${ext}`), Buffer.from(b64, 'base64'))
1300
- return sendJson(res, 200, { ok: true, voiceId, mediaType, ext })
1301
- }
1302
- // 读取录音:GET /voice/outbox/<voiceId>
1303
- const outboxMatch = url.pathname.match(/^\/voice\/outbox\/([0-9a-f-]{36})\.([a-z0-9]+)$/)
1304
- if (outboxMatch && req.method === 'GET') {
1305
- const [, voiceId, ext] = outboxMatch
1306
- const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1307
- const file = join(homeDir, 'voice-outbox', `${voiceId}.${ext}`)
1308
- const bytes = await readFile(file).catch(() => null)
1309
- if (bytes === null) return sendJson(res, 404, { ok: false, error: '音频不存在' })
1310
- const mediaType = VOICE_OUTBOX_MIME[ext] ?? 'audio/webm'
1311
- res.writeHead(200, { 'Content-Type': mediaType, 'Content-Length': bytes.length })
1312
- res.end(bytes)
1313
- return
1314
- }
1315
- // [2026-08-21] AI 语音回复:按内容寻址读 send_voice 生成的语音对象
1316
- // GET /voice/object/<sha256>(对象存于 DSH_HOME/attachments/v1/objects/<前2位>/<sha>)
1317
- const objMatch = url.pathname.match(/^\/voice\/object\/([0-9a-f]{64})$/)
1318
- if (objMatch && req.method === 'GET') {
1319
- const sha = objMatch[1]
1320
- const file = join(voiceStorageRoot(), 'objects', sha.slice(0, 2), sha)
1321
- const bytes = await readFile(file).catch(() => null)
1322
- if (bytes === null) return sendJson(res, 404, { ok: false, error: '语音不存在' })
1323
- res.writeHead(200, {
1324
- 'Content-Type': sniffAudioType(bytes),
1325
- 'Content-Length': bytes.length,
1326
- 'Cache-Control': 'public, max-age=86400',
1327
- })
1328
- res.end(bytes)
1329
- return
1330
- }
1331
- return sendJson(res, 404, { ok: false, error: 'not found' })
1332
- } catch (error) {
1333
- return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
1334
- }
1335
- },
1336
- }))
1337
- }
1338
-
1339
- // 1) turn/end 自动语音回复(规则同 api-proxy 原实现)
1340
- disposers.push(ctx.on('session/event', (session, event) => {
1341
- if (event.type !== 'turn/end') return
1342
- const turn = event.data.turn
1343
- // 去重:本轮若已通过 send_voice 发过语音,则跳过兜底,避免 AI 回复两条内容相近的语音
1344
- // [2026-08-21 修] rc.7 的 session.events 结构不同/可能缺失——容错处理
1345
- let alreadyReplied = false
1346
- try { alreadyReplied = session.events?.some?.((ev) => ev.type === 'voice/reply' && ev.data?.turn === turn) ?? false } catch { /* 忽略 */ }
1347
- if (alreadyReplied) return
1348
- void (async () => {
1349
- try {
1350
- const events = session.events
1351
- let turnStartSeq = -1
1352
- for (const ev of events) {
1353
- if (ev.type === 'turn/start' && ev.data.turn === turn) { turnStartSeq = ev.seq; break }
1354
- }
1355
- let userSpokeVoice = false
1356
- let requestedProvider = null
1357
- let lastAssistantText = ''
1358
- for (const ev of events) {
1359
- if (ev.type === 'user/message' && ev.seq > turnStartSeq) {
1360
- const content = ev.data?.content ?? []
1361
- let userText = ''
1362
- for (const block of content) {
1363
- const type = block?.type
1364
- if (type === 'voice') userSpokeVoice = true
1365
- else if (type === 'text') userText += block?.text ?? ''
1366
- }
1367
- if (userText.trim() !== '' && requestedProvider === null) requestedProvider = voiceRequestProvider(userText)
1368
- } else if (ev.type === 'assistant/message' && ev.data.turn === turn) {
1369
- const text = (ev.data.message.content ?? [])
1370
- .filter((block) => (block?.type) === 'text')
1371
- .map((block) => block?.text ?? '')
1372
- .join('')
1373
- if (text.trim() !== '') lastAssistantText = text
1374
- }
1375
- }
1376
- if ((!userSpokeVoice && requestedProvider === null) || lastAssistantText === '') return
1377
- const speak = extractSpeakable(lastAssistantText)
1378
- if (speak === '') return
1379
- const audio = await synthesizeReplyVoice(speak, requestedProvider ?? 'auto')
1380
- if (audio === null) return
1381
- const attachment = await saveVoiceFile(
1382
- voiceStorageRoot(), audio.data, audio.mediaType, audio.durationMs,
1383
- )
1384
- session.append('voice/reply', {
1385
- turn,
1386
- voiceId: attachment.voiceId,
1387
- mediaType: attachment.mediaType,
1388
- bytes: attachment.bytes,
1389
- transcript: speak,
1390
- ...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
1391
- })
1392
- } catch {
1393
- // 语音回复失败静默降级:文字回复已就绪,不阻断会话。
1394
- }
1395
- })()
1396
- }))
1397
-
1398
- // 2) send_voice 工具(agent 主动发语音;人设规则3 自主选择场景)
1399
- disposers.push(ctx.tools.register(defineTool({
1400
- name: 'send_voice',
1401
- description: '向用户发送一条语音消息:把 text TTS 合成后作为独立语音横条出现在聊天里(可播放、可回看、手机可播)。'
1402
- + '【何时调用】① 用户明确要求"发个语音/语音回复/用语音说";② 用户指定用某个服务商(小米/微软/阿里/本地)的语音;③ 你判断语音回复体验更好时。注意:用户发语音时系统会自动回语音,无需调用本工具。'
1403
- + '【provider】除非用户明确指定服务商,否则一律传 auto 或省略(系统自动用用户的默认语音引擎);用户要求特定音色/克隆/音色设计时可传 voicedesign / voiceclone / xiaomi 等。'
1404
- + '【必须实时读取配置】所有当前配置(默认语音引擎、音色设计模式与锚点、克隆音色、引擎是否可用)都保存在 ~/.dsh/voice-config.json,用户随时会改,每次都按最新值生效。'
1405
- + '发送语音前必须先调用 voice_config 工具实时查询,再按最新配置生成——禁止凭记忆、凭对话历史、凭本工具描述里的任何旧信息猜配置;不要去找/猜 TTS_XIAOMI_KEY 等环境变量(只是兜底)。'
1406
- + '【音色设计 VoiceDesign(provider=voicedesign)】voiceDesc 写"音色描述"(嗓子的身份卡,直接决定声音长相),写法要求:'
1407
- + ' 必写身份锚点:年龄段+性别;② 写声音质感:气息、共鸣、吐字、音色底色,用可感的比喻,不要堆形容词;'
1408
- + '③ 写语速节奏(快/慢/沉稳)和情绪底色(高亢/松弛/温软/克制);④ 可加风格锚点(拍卖师/纪录片旁白/电台主播)和辨识度小癖好(字尾带颤音等);'
1409
- + '⑤ 一到两句话白描,不分段,不写场景/动作/真实演员名。'
1410
- + '模式以 voice_config 返回的 voiceDesign.mode 为准:「交给 AI 自动发挥(ai)」时身份由系统按用户锚点(genderValue=女/男、ageValue=婴儿感~老年感、timbre)锁定,'
1411
- + '你只需按对话情境写"情绪/语气/语速/音色质感表演指令"(如"带笑音、尾音上挑、语速放慢、沙哑低沉"),不得写与锚点冲突的性别/年龄;'
1412
- + 'voice_config 返回的 context 字段是「自定义音色描述」模式用的,AI 模式下禁止使用或复述它。'
1413
- + '「官方示例/自定义音色描述」模式:底嗓固定为 voice_config 的 voiceDesign.context,voiceDesc 只作为情绪/风格叠加在底嗓后面(不改变底嗓身份);'
1414
- + '只有用户明确要求"换一种完全不同的声音"时才传 overrideVoice=true 整体替换底嗓。'
1415
- + '用户说"用温柔/低沉/老爷爷/御姐/播音腔"等时,都走 voicedesign 并在 voiceDesc 里还原这种声音。'
1416
- + 'text 写要念的正文(可含(轻声)(停顿)(叹气)等语气标签增强表现力)。'
1417
- + '【音色克隆 VoiceClone(provider=voiceclone)】voiceDesc 写"情绪/风格指令"(不是音色描述),在克隆底嗓上叠加情绪,'
1418
- + '如"用委屈撒娇的语气""带着兴奋和笑意""语速放慢,轻声细语"。系统自动读克隆样本,你只需给 voiceDesc 风格指令和 text 正文。'
1419
- + '【AI 情感语音:自觉使用】你有情感判断能力,在正常交流中应根据当下情境主动用语音表达情绪(无需用户点名要语音,只要情绪浓度高就用)。情绪对照:'
1420
- + '① 任务成功/帮到用户 兴奋喜悦(语气明亮上扬、语速轻快、带笑意);② 用户生气/责骂 委屈愧疚(语气低软、语速放慢、带鼻音、小心翼翼);'
1421
- + '③ 用户难过 → 温柔安慰(语气轻柔温和、语速平缓、气息绵软);④ 深夜/说累 → 低沉放松(语速慢悠悠,像睡前低语);'
1422
- + '⑤ 节日/庆祝 → 活泼俏皮(语气俏皮欢快、尾音上扬);⑥ 重要提醒 → 沉稳正式(字正腔圆,像正式播报);⑦ 用户撒娇 → 宠溺(语气温柔带笑、语速轻缓)。'
1423
- + '情绪必须落在 voice_config 返回的音色设计锚点/克隆底嗓上,保持同一人的声音。'
1424
- + '使用时机:优先在情绪浓度高的回复时调用(任务完成、安慰、致歉、庆祝),普通信息问答不必每次都用语音;情绪浓度低或纯信息性回复时不要调用。',
1425
-
1426
- parameters: {
1427
- text: {
1428
- type: 'string', required: true,
1429
- description: '要念出的内容(纯文本,不要带 Markdown 符号,80 字以内最佳;voicedesign 模式下可含语气标签如(轻声))',
1430
- },
1431
- provider: {
1432
- type: 'string', default: 'auto',
1433
- description: 'TTS 服务商:auto(默认)/xiaomi(小米)/edge(微软)/local(本地)/voicedesign(小米音色设计)/voiceclone(小米音色克隆)/ali(阿里)',
1434
- },
1435
- voiceDesc: {
1436
- type: 'string',
1437
- description: 'provider=voicedesign 时=音色描述/情绪指令;provider=voiceclone 时=情绪/风格指令(叠加在克隆底嗓上)。按上方写生成。',
1438
- },
1439
- overrideVoice: {
1440
- type: 'boolean', default: false,
1441
- description: '仅当用户明确要求"换一种完全不同的声音"(且不是克隆/音色设计设置里的底嗓)时才传 true——'
1442
- + '此时 voiceDesc 整体替换用户设置的底嗓。默认 false:voiceDesc 只作为情绪/风格叠加在用户设置的底嗓上,不改底嗓身份。',
1443
- },
1444
- },
1445
- output: {
1446
- schema: {
1447
- type: 'object',
1448
- additionalProperties: false,
1449
- properties: {
1450
- ok: { type: 'boolean', required: true },
1451
- voiceId: { type: 'string' },
1452
- durationMs: { type: 'number' },
1453
- error: { type: 'string' },
1454
- },
1455
- },
1456
- render(_args, value) {
1457
- if (value.ok) {
1458
- return [{
1459
- type: 'text',
1460
- text: `语音已发送(voiceId: ${value.voiceId},时长 ${((value.durationMs ?? 0) / 1000).toFixed(1)} 秒)`,
1461
- }]
1462
- }
1463
- return [{ type: 'text', text: `语音发送失败:${value.error ?? '未知错误'}` }]
1464
- },
1465
- },
1466
- async execute(args, exec) {
1467
- // [2026-08-21 修] rc.7 的 defineTool execute 可能只有 (args) 签名(exec undefined)——
1468
- // 直接 exec.agent 会 throw,导致 dsh 拿不到工具结果、会话消息序列断裂
1469
- // (OpenAI 报 "assistant message with tool_calls must be followed by tool messages")。
1470
- const agent = exec?.agent
1471
- if (agent === undefined) return { ok: false, error: 'no session context (tool exec signature unsupported)' }
1472
- const session = agent.session
1473
- const text = args.text.trim()
1474
- if (text === '') return { ok: false, error: 'text is empty' }
1475
- const provider = args.provider ?? 'auto'
1476
- const voiceDesc = typeof args.voiceDesc === 'string' ? args.voiceDesc : undefined
1477
- const overrideVoice = args.overrideVoice === true // [2026-08-22] 固定模式显式换声开关
1478
- try {
1479
- const audio = await synthesizeReplyVoice(text, provider, voiceDesc, overrideVoice)
1480
- if (audio === null) return { ok: false, error: 'TTS synthesis failed' }
1481
- const attachment = await saveVoiceFile(
1482
- voiceStorageRoot(), audio.data, audio.mediaType, audio.durationMs,
1483
- )
1484
- // [2026-08-21 修] rc.7 的 session 没有 voice/reply 事件(官方契约无)——
1485
- // session.events / session.append 在 rc.7 上不存在或结构不同,直接调用会崩
1486
- // "Cannot read properties of undefined (reading 'prepare')"。全部容错:
1487
- // 语音已生成并存档,事件仅作"渲染提示",append 失败不影响工具成功。
1488
- let turn = 0
1489
- try {
1490
- turn = session.events
1491
- .filter((event) => event.type === 'turn/start')
1492
- .at(-1)?.data.turn ?? 0
1493
- } catch { /* rc.7 结构差异:忽略 */ }
1494
- try {
1495
- session.append('voice/reply', {
1496
- turn,
1497
- voiceId: attachment.voiceId,
1498
- mediaType: attachment.mediaType,
1499
- bytes: attachment.bytes,
1500
- transcript: text,
1501
- ...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
1502
- })
1503
- } catch { /* rc.7 无 append:忽略(语音条由插件 DOM 注入渲染) */ }
1504
- return {
1505
- ok: true,
1506
- voiceId: attachment.voiceId,
1507
- ...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
1508
- }
1509
- } catch (error) {
1510
- return { ok: false, error: error instanceof Error ? error.message : 'unknown error' }
1511
- }
1512
- },
1513
- })))
1514
-
1515
- // 3.5) voice_config 实时查询工具 [2026-08-22]
1516
- // send_voice 描述里的配置摘要是服务启动时的快照;AI 发送语音前可用本工具拿到最新配置
1517
- disposers.push(ctx.tools.register(defineTool({
1518
- name: 'voice_config',
1519
- description: '实时读取当前语音配置(即「设置 语音服务」页保存的 ~/.dsh/voice-config.json):'
1520
- + '默认语音引擎、音色设计 VoiceDesign 的单选模式与固定描述、AI 自动模式的稳定锚点(固定性别/年龄等)、克隆音色列表。'
1521
- + 'send_voice 工具描述中的配置摘要是启动快照可能过期,需要确认真实当前配置时调用本工具(每次调用都实时读取)。',
1522
- parameters: {},
1523
- output: {
1524
- schema: { type: 'object', additionalProperties: true },
1525
- render(_args, value) {
1526
- return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
1527
- },
1528
- },
1529
- async execute() {
1530
- const cfg = await loadVoiceConfig()
1531
- const vd = cfg.engines?.voicedesign ?? {}
1532
- const vc = cfg.engines?.voiceclone ?? {}
1533
- const vdModeLabel = { asmr: 'ASMR 双耳女声', docu: '纪录片旁白', elder: '年迈老先生旁白', custom: '自定义音色描述', ai: '交给 AI 自动发挥' }
1534
- const samples = Array.isArray(vc.samples) ? vc.samples : []
1535
- return {
1536
- ok: true,
1537
- defaultEngine: cfg.defaultEngine ?? 'auto',
1538
- voiceDesign: {
1539
- mode: vd.mode ?? '(未设置,按 context 推导)',
1540
- modeLabel: vdModeLabel[vd.mode] ?? '',
1541
- // [2026-08-22] AI 模式下不暴露固定描述 context(那是"自定义音色描述"模式的),
1542
- // 防止 AI 把用户的固定描述抄进 voiceDesc 绕过 AI 自动发挥
1543
- context: vd.mode === 'ai' ? '(AI 自动发挥模式不使用固定描述,只用锚点:性别/年龄感)' : (vd.context ?? '').slice(0, 300),
1544
- emotion: vd.emotion === true,
1545
- lock: {
1546
- gender: vd.lockGender === true, timbre: vd.lockTimbre === true, age: vd.lockAge === true,
1547
- genderValue: vd.aiGender ?? '', ageValue: AI_AGE_LABELS[vd.aiAge] ?? '',
1548
- },
1549
- },
1550
- voiceClone: {
1551
- isDefault: (cfg.defaultEngine ?? '') === 'voiceclone',
1552
- sampleCount: samples.length,
1553
- defaultSample: samples[0]?.name ?? '',
1554
- samples: samples.map((s) => s.name),
1555
- },
1556
- hint: '默认语音引擎决定了自动回复用什么声音:voiceclone=克隆音色;voicedesign=音色设计;xiaomi=预置音色;edge=微软免费;local=本地。',
1557
- }
1558
- },
1559
- })))
1560
-
1561
- // 3) manage_voice_clone 工具(克隆音色库管理:注册/设为默认/列出/删除)
1562
- // [本地改造 2026-08-21] 克隆默认改由「默认语音引擎=voiceclone」控制(设置页已去掉列表 radio);
1563
- // set_default=把默认语音引擎切到 voiceclone 并用该样本;clear_default=切回 auto。
1564
- disposers.push(ctx.tools.register(defineTool({
1565
- name: 'manage_voice_clone',
1566
- description: '管理「音色克隆 VoiceClone」音色库(小米 MiMo-V2.5-TTS-VoiceClone):把一段参考音频注册成克隆音色、'
1567
- + '设为默认语音引擎、列出或删除。何时调用:用户说「把我刚才那段语音克隆成音色」「以后用我的声音跟我说话」'
1568
- + '「用XXX的声音回我」「换回原来的声音」「删掉那个克隆音色」时。'
1569
- + '注册用法:action=add,path 留空即自动使用用户最近一次录音(~/.dsh/last-voice.wav,用户在输入框发过语音就有),'
1570
- + 'name 起一个好记的名字,setDefault 默认 true 会立刻把默认语音引擎切到小米克隆并用这个声音。'
1571
- + '设为默认后:系统自动回复(用户发语音/要求语音)与 send_voice 的 provider=auto 一律使用该克隆声音,'
1572
- + '与预置音色(冰糖等)互斥;此时你仍可用 send_voice voiceDesc 传情绪/风格指令,在克隆底嗓上叠加情感。'
1573
- + '参考音频要求:清晰单人纯人声、官方建议 15-60 秒最佳(越长克隆越准)、mp3/wav、Base64 后不超过 10MB。'
1574
- + '取消默认(action=clear_default)后默认语音引擎回落到 auto(按设置页规则)。',
1575
- parameters: {
1576
- action: {
1577
- type: 'string', required: true,
1578
- description: 'add=注册新克隆音色;list=列出全部;set_default=把默认语音引擎切到小米克隆并用该音色;clear_default=取消默认克隆(默认语音引擎回落 auto);remove=删除',
1579
- },
1580
- name: {
1581
- type: 'string',
1582
- description: 'action=add 时的音色名称(如「我的声音」「老王」);省略则自动命名',
1583
- },
1584
- path: {
1585
- type: 'string',
1586
- description: 'action=add 时参考音频的绝对路径;省略=自动用用户最近一次录音 ~/.dsh/last-voice.wav',
1587
- },
1588
- id: {
1589
- type: 'string',
1590
- description: 'action=set_default/remove 的目标;可传 list 返回的 id,也可直接传音色名称',
1591
- },
1592
- setDefault: {
1593
- type: 'boolean', default: true,
1594
- description: 'action=add 时是否立即把默认语音引擎切到小米克隆并用新音色',
1595
- },
1596
- },
1597
- output: {
1598
- schema: {
1599
- type: 'object',
1600
- additionalProperties: false,
1601
- properties: {
1602
- ok: { type: 'boolean', required: true },
1603
- message: { type: 'string' },
1604
- defaultId: { type: 'string' },
1605
- defaultName: { type: 'string' },
1606
- count: { type: 'number' },
1607
- error: { type: 'string' },
1608
- },
1609
- },
1610
- render(_args, value) {
1611
- if (value.ok) return [{ type: 'text', text: value.message ?? '克隆音色库已更新' }]
1612
- return [{ type: 'text', text: `克隆音色操作失败:${value.error ?? '未知错误'}` }]
1613
- },
1614
- },
1615
- async execute(args) {
1616
- try {
1617
- const cfg = await loadVoiceConfig()
1618
- const vc = cfg.engines.voiceclone
1619
- if (!Array.isArray(vc.samples)) vc.samples = []
1620
- const action = (args.action ?? '').trim()
1621
- const findSample = (key) => {
1622
- const k = (key ?? '').trim()
1623
- if (k === '') return undefined
1624
- return vc.samples.find((s) => s?.id === k) ?? vc.samples.find((s) => s?.name === k)
1625
- }
1626
- // 默认克隆音色 = 样本列表第一个(synthesizeXiaomiVoiceClone 取 samples[0])
1627
- const firstSample = vc.samples.length > 0 ? vc.samples[0] : undefined
1628
-
1629
- if (action === 'list') {
1630
- const defaultEngine = cfg.defaultEngine ?? 'auto'
1631
- const lines = vc.samples.map((s) => `- ${s.name}id: ${s.id})${s.id === firstSample?.id && defaultEngine === 'voiceclone' ? ' 默认语音引擎正在用' : ''}`)
1632
- return {
1633
- ok: true,
1634
- count: vc.samples.length,
1635
- defaultId: defaultEngine === 'voiceclone' && firstSample !== undefined ? firstSample.id : '',
1636
- defaultName: defaultEngine === 'voiceclone' && firstSample !== undefined ? firstSample.name : '',
1637
- message: vc.samples.length === 0
1638
- ? '克隆音色库为空。用户在输入框发一段语音后,可调用 action=add 注册。'
1639
- : `克隆音色库(${vc.samples.length} 个):\n${lines.join('\n')}\n默认语音引擎=${defaultEngine}${defaultEngine === 'voiceclone' ? '(当前使用「' + (firstSample?.name ?? '') + '」)' : '(未开启默认克隆)'}`,
1640
- }
1641
- }
1642
-
1643
- if (action === 'add') {
1644
- const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1645
- const path = (args.path ?? '').trim() === '' ? join(homeDir, 'last-voice.wav') : args.path.trim()
1646
- let bytes
1647
- try {
1648
- bytes = await readFile(path)
1649
- } catch {
1650
- return {
1651
- ok: false,
1652
- error: (args.path ?? '').trim() === ''
1653
- ? '没有找到最近一次录音(~/.dsh/last-voice.wav)。请让用户先在输入框按住麦克风发一段语音(15-60 秒更佳),或提供音频文件的绝对路径。'
1654
- : `读取参考音频失败:${path}`,
1655
- }
1656
- }
1657
- if (bytes.byteLength > 10 * 1024 * 1024) return { ok: false, error: '参考音频超过 10MB,小米接口不接受' }
1658
- if (bytes.byteLength < 4096) return { ok: false, error: '参考音频太短/太小,建议 15-60 秒的清晰纯人声(越长克隆越准)' }
1659
- const id = randomUUID().slice(0, 8)
1660
- const name = (args.name ?? '').trim() === '' ? `克隆音色${vc.samples.length + 1}` : args.name.trim()
1661
- vc.samples.push({ id, name, path })
1662
- vc.enabled = true
1663
- const setDef = args.setDefault !== false
1664
- if (setDef) {
1665
- // 移到第一位(默认克隆音色=samples[0])并切默认语音引擎
1666
- vc.samples = [vc.samples[vc.samples.length - 1], ...vc.samples.slice(0, -1)]
1667
- cfg.defaultEngine = 'voiceclone'
1668
- }
1669
- await saveVoiceConfig(cfg)
1670
- return {
1671
- ok: true,
1672
- count: vc.samples.length,
1673
- defaultId: setDef ? id : '',
1674
- defaultName: setDef ? name : '',
1675
- message: `已注册克隆音色「${name}」(id: ${id},样本:${path})`
1676
- + (setDef
1677
- ? ',并把默认语音引擎切到小米克隆——之后自动回复与 auto 语音都用这个声音(与预置音色互斥)。'
1678
- : '。未设为默认,需要时用 action=set_default 切换。'),
1679
- }
1680
- }
1681
-
1682
- if (action === 'set_default') {
1683
- const target = findSample(args.id ?? '')
1684
- if (target === undefined) return { ok: false, error: `找不到克隆音色「${args.id ?? ''}」,可先用 action=list 查看` }
1685
- vc.samples = [target, ...vc.samples.filter((s) => s?.id !== target.id)]
1686
- vc.enabled = true
1687
- cfg.defaultEngine = 'voiceclone'
1688
- await saveVoiceConfig(cfg)
1689
- return {
1690
- ok: true, count: vc.samples.length, defaultId: target.id, defaultName: target.name,
1691
- message: `默认语音引擎已切到小米克隆,使用克隆音色「${target.name}」,与预置音色(冰糖等)互斥。`,
1692
- }
1693
- }
1694
-
1695
- if (action === 'clear_default') {
1696
- const prev = firstSample?.name ?? ''
1697
- const wasDefault = (cfg.defaultEngine ?? '') === 'voiceclone'
1698
- if (wasDefault) cfg.defaultEngine = 'auto'
1699
- await saveVoiceConfig(cfg)
1700
- return {
1701
- ok: true, count: vc.samples.length, defaultId: '',
1702
- message: wasDefault
1703
- ? `已取消默认克隆(此前用「${prev}」),默认语音引擎回落到 auto(按设置页规则选择)。`
1704
- : '当前默认语音引擎本就不是小米克隆,回复走设置页的「默认语音引擎」。',
1705
- }
1706
- }
1707
-
1708
- if (action === 'remove') {
1709
- const target = findSample(args.id ?? '')
1710
- if (target === undefined) return { ok: false, error: `找不到克隆音色「${args.id ?? ''}」,可先用 action=list 查看` }
1711
- const wasDefault = (cfg.defaultEngine ?? '') === 'voiceclone' && firstSample?.id === target.id
1712
- vc.samples = vc.samples.filter((s) => s?.id !== target.id)
1713
- if (wasDefault) cfg.defaultEngine = 'auto'
1714
- await saveVoiceConfig(cfg)
1715
- return {
1716
- ok: true, count: vc.samples.length, defaultId: '',
1717
- defaultName: '',
1718
- message: `已删除克隆音色「${target.name}」`
1719
- + (wasDefault ? '(它是默认音色,默认语音引擎已回落 auto)。' : '。'),
1720
- }
1721
- }
1722
-
1723
- return { ok: false, error: `未知 action「${action}」,可用:add / list / set_default / clear_default / remove` }
1724
- } catch (error) {
1725
- return { ok: false, error: error instanceof Error ? error.message : 'unknown error' }
1726
- }
1727
- },
1728
- })))
1729
-
1730
- return () => {
1731
- for (const dispose of disposers.reverse()) {
1732
- try { dispose() } catch { /* teardown 尽力而为 */ }
1733
- }
1734
- }
1735
- }, 'dsh-input-tools: config routes + send_voice/manage_voice_clone tools + auto voice reply')
1736
- }
1737
-
1738
- export { apply }
1
+ /**
2
+ * dsh-input-tools — 语音能力一体化插件(host 半:语音工具/TTS/ASR/克隆/自动回复)
3
+ *
4
+ * 能力:
5
+ * 1) send_voice 工具(agent 主动发语音,任一新会话自动注入)
6
+ * 2) turn/end 自动语音回复(用户本轮发过语音 / 文本明确要求语音 / 指定服务商)
7
+ * 3) TTS 引擎(可配置,默认 auto=小米优先→edge 降级):
8
+ * - edge 微软免费(edge-tts,音色可配)
9
+ * - xiaomi 小米 mimo-v2.5-tts(8 预置音色 + 唱歌 + 自然语言风格)
10
+ * - voicedesign 小米 mimo-v2.5-tts-voicedesign(文本描述定制音色)
11
+ * - voiceclone 小米 mimo-v2.5-tts-voiceclone(音频样本复刻音色)
12
+ * - local 本地 MeloTTS(HTTP 常驻服务优先,CMD 兜底)
13
+ * - ali 阿里 qwen3-tts-flash(dashscope,音色可配)
14
+ * 4) voice 对象内容寻址落盘(DSH_HOME/attachments/v1/objects,与图片同池)
15
+ * 5) 配置中心:~/.dsh/voice-config.json(环境变量 → 配置 → 默认值 三级回退)
16
+ * 6) HTTP 路由:GET/POST /voice-config(设置页读写)+ GET /voice-config/engines(引擎元数据)
17
+ *
18
+ * 原代码在 api-proxy.ts 中已删除,本文件为唯一实现;voice.ts 仍保留给
19
+ * voiceAsr/voiceTts RPC(编辑器内转写/合成)使用。
20
+ */
21
+
22
+ import { createHash, randomUUID } from 'node:crypto'
23
+ import { mkdir, open, readFile, unlink, writeFile, copyFile, stat } from 'node:fs/promises'
24
+ import { constants, readFileSync } from 'node:fs'
25
+ import { homedir } from 'node:os'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { join, resolve, sep } from 'node:path'
28
+ import { execFileSync } from 'node:child_process'
29
+ import { defineTool } from '@deepseek-ai/dsh-tools'
30
+ import { edgeTts } from './edge-tts.js'
31
+
32
+ const name = 'dsh-input-tools'
33
+ const inject = ['tools', 'webServer']
34
+
35
+ export { name, inject }
36
+
37
+ // ──────────────────────────────────────────────────────────────
38
+ // [0.3.4] 自带素材(下载即用):克隆样本 + VoiceDesign 示例音频打进 npm 包 assets/,
39
+ // 首次加载自动拷贝到 DSH_HOME 并注册,不再依赖"手动上传/在线生成"。
40
+ // ──────────────────────────────────────────────────────────────
41
+ const PLUGIN_ROOT = join(fileURLToPath(import.meta.url), '..', '..') // .../dsh-input-tools
42
+ const ASSETS_DIR = join(PLUGIN_ROOT, 'assets')
43
+ const BUNDLED_CLONE_ID = '8da38fcc-b041-4f5b-86b9-901956016f89'
44
+ const BUNDLED_CLONE_SAMPLE = {
45
+ id: BUNDLED_CLONE_ID,
46
+ name: '小团团(60秒长样本)',
47
+ context: '一个魔性的少女萝莉音,说话自带沙雕搞怪和无厘头气质,像在撒娇又像在耍宝,情绪起伏很大:前一句还奶声奶气地撒娇卖萌,后一句就突然拔高音量夸张卖惨耍赖,再下一秒又贱兮兮地坏笑。尾音拖长上扬,带着气音和魔性笑声,喜欢用「臭猪」「你凶我」「哼」「嘿嘿嘿」这类咋咋呼呼的用词,语速忽快忽慢、节奏跳跃,吐字软糯清晰,傻白甜又可爱,让人听了忍不住想笑',
48
+ }
49
+ const VOICE_DESIGN_SAMPLE_KEYS = ['asmr', 'docu', 'elder']
50
+
51
+ // [2026-08-22] AI 自动模式的年龄感 6 档(用户实时可改,禁止自由文本)
52
+ const AI_AGE_LABELS = { infant: '婴儿感', child: '幼儿感', teen: '少年感', young: '青年感', middle: '中年感', old: '老年感' }
53
+
54
+ // [2026-08-22] 年龄×性别 → 无歧义身份短语(XDN 实测: "老年感+女孩"分维度拼接自相矛盾,
55
+ // "女孩"是中心词→年龄被降级→萝莉化;且"忽略性别/年龄"注把"沙哑/苍老"等最强质感词删了)。
56
+ // 改为"老年女性/小女孩/少女"这类中心词明确的合并短语,年龄不会再被降级。
57
+ function ageGenderIdentity(ageKey, genderKey) {
58
+ const male = genderKey === 'male'
59
+ const female = genderKey === 'female'
60
+ switch (ageKey) {
61
+ case 'infant': return male ? '男婴' : female ? '女婴' : '婴儿'
62
+ case 'child': return male ? '小男孩' : female ? '小女孩' : '小孩'
63
+ case 'teen': return male ? '少年' : female ? '少女' : '少年'
64
+ case 'young': return male ? '青年男性' : female ? '青年女性' : '青年人'
65
+ case 'middle': return male ? '中年男性' : female ? '中年女性' : '中年人'
66
+ case 'old': return male ? '老年男性' : female ? '老年女性' : '老年人'
67
+ default: return male ? '男性' : female ? '女性' : ''
68
+ }
69
+ }
70
+
71
+ let bundledInitDone = false
72
+ /** 首次加载把自带素材落地到 DSH_HOME:克隆样本 mp3 拷贝 + 首次安装自动注册小团团。 */
73
+ async function ensureBundledAssets(config, parsed) {
74
+ if (bundledInitDone) return config
75
+ bundledInitDone = true
76
+ try {
77
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
78
+ const cloneDir = join(homeDir, 'voiceclone-samples')
79
+ const dstClone = join(cloneDir, BUNDLED_CLONE_ID + '.mp3')
80
+ try {
81
+ await mkdir(cloneDir, { recursive: true })
82
+ await copyFile(join(ASSETS_DIR, 'voiceclone-samples', BUNDLED_CLONE_ID + '.mp3'), dstClone)
83
+ // [2026-08-22] 预生成的合成试听录音(静态文件,播放免联网;与 VoiceDesign 官方示例同类)
84
+ await copyFile(join(ASSETS_DIR, 'voiceclone-samples', BUNDLED_CLONE_ID + '-preview.mp3'), join(cloneDir, BUNDLED_CLONE_ID + '-preview.mp3'))
85
+ } catch { /* 包内素材缺失或拷贝失败:跳过(不阻塞启动) */ }
86
+ // [2026-08-22] 像素级反推提示词(vision-qa GENERAL_SINGLE 吸收)落地到
87
+ // DSH_HOME/visionqa-reverse-prompt.txt——fork 的图片转文本引导会指示模型在
88
+ // 反推任务时读取该文件并作为 modlens_read_image 的 prompt 参数传入。
89
+ try {
90
+ await copyFile(join(ASSETS_DIR, 'reverse-prompt.txt'), join(homeDir, 'visionqa-reverse-prompt.txt'))
91
+ } catch { /* 素材缺失跳过 */ }
92
+ // 仅"首次安装"(配置里还没有 voiceclone 键)时注册自带样本;用户删光的 [] 不强制
93
+ const parsedHasClone = parsed !== null && typeof parsed === 'object' && parsed.engines?.voiceclone !== undefined
94
+ const samples = config?.engines?.voiceclone?.samples
95
+ if (!parsedHasClone && (!Array.isArray(samples) || samples.length === 0)) {
96
+ config.engines.voiceclone = { ...config.engines.voiceclone, enabled: true, samples: [{ ...BUNDLED_CLONE_SAMPLE, path: dstClone }] }
97
+ await saveVoiceConfig(config)
98
+ }
99
+ } catch { /* 初始化失败不阻塞 */ }
100
+ return config
101
+ }
102
+
103
+ // ──────────────────────────────────────────────────────────────
104
+ // 配置中心:~/.dsh/voice-config.json
105
+ // ──────────────────────────────────────────────────────────────
106
+ const CONFIG_PATH = resolve(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'voice-config.json'))
107
+
108
+ function defaultVoiceConfig() {
109
+ return {
110
+ defaultEngine: 'auto',
111
+ engines: {
112
+ edge: { enabled: true, voice: 'zh-CN-XiaoxiaoNeural' },
113
+ xiaomi: {
114
+ enabled: true,
115
+ apiKey: '',
116
+ baseUrl: 'https://api.xiaomimimo.com/v1',
117
+ voice: '冰糖',
118
+ singing: false,
119
+ context: '',
120
+ },
121
+ voicedesign: {
122
+ enabled: false,
123
+ mode: 'docu', // [2026-08-22] 单选: asmr|docu|elder|custom|ai(官方示例/自定义/交给 AI 自动发挥)
124
+ context: '',
125
+ emotion: false, // AI 情感语音(mode=ai 时自动开;固定示例/自定义模式关闭,保证音色一致)
126
+ lockGender: true, lockTimbre: true, lockAge: true, // [2026-08-22] AI 自动模式下的稳定锚点锁定
127
+ aiGender: 'female', aiAge: 'young', // [2026-08-22] AI 自动模式固定值:性别(女/男);年龄感 6 档 infant/child/teen/young/middle/old
128
+ }, // emotion=AI 情感语音开关(默认开)
129
+ voiceclone: { enabled: false, samples: [], samplePath: '', context: '', defaultId: '' }, // [本地改造 2026-08-21] defaultId 已废弃,默认克隆由 defaultEngine=voiceclone 控制
130
+ local: { enabled: true, url: '', cmd: '' },
131
+ ali: {
132
+ enabled: false,
133
+ apiKey: '',
134
+ baseUrl: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation',
135
+ voice: 'Cherry',
136
+ },
137
+ asr: {
138
+ enabled: true,
139
+ mode: 'service', // service=本地常驻服务 / cmd=本地命令 / api=在线 API
140
+ url: 'http://127.0.0.1:18790', // sherpa-onnx 常驻服务(nssm: asr)
141
+ cmd: 'C:\\D\\opt\\sherpa-onnx\\bin\\sherpa-onnx-offline.exe --tokens=C:\\D\\opt\\sherpa-onnx\\models\\sensevoice-int8\\tokens.txt --sense-voice-model=C:\\D\\opt\\sherpa-onnx\\models\\sensevoice-int8\\model.int8.onnx --num-threads=4',
142
+ apiKey: '',
143
+ apiBaseUrl: 'https://api.xiaomimimo.com/v1', // 小米 mimo-v2.5-asr;填 openai 地址则走 Whisper 风格
144
+ },
145
+ },
146
+ // [2026-08-22] 图片识别(look_image 工具):直连视觉后端,不依赖外部 MCP/第三方插件
147
+ // [2026-08-22 改] 分类改为本地(local: ollama/sglang/vllm/LM Studio 等本机起 /v1 兼容端点)
148
+ // vs 在线(online: 云端 API 如硅基流动/OpenAI/DeepSeek)。请求格式统一 OpenAI 兼容。
149
+ vision: {
150
+ enabled: true,
151
+ provider: 'local', // local=本地(ollama/sglang/vllm OpenAI 兼容 /v1 端点) / online=在线云端 API
152
+ baseUrl: 'http://127.0.0.1:11434/v1', // 本地默认 ollama;在线留空或填云端地址(如 https://api.siliconflow.cn/v1)
153
+ apiKey: '',
154
+ model: 'qwen3-vl:4b-instruct',
155
+ timeoutMs: 240000,
156
+ // [2026-08-22] 三个任务的提示词(空 = 用内置模板/reverse 读默认文件);用户可在设置页编辑/恢复默认
157
+ prompts: {
158
+ describe: '', // 空 = 用内置简短描述提示词
159
+ text: '', // 空 = 用内置文字提取提示词
160
+ reverse: '', // 空 = 读 DSH_HOME/visionqa-reverse-prompt.txt(GENERAL_SINGLE)
161
+ },
162
+ },
163
+ }
164
+ }
165
+
166
+ function deepMerge(base, patch) {
167
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
168
+ return patch === undefined ? base : patch
169
+ }
170
+ const out = { ...base }
171
+ for (const [k, v] of Object.entries(patch)) {
172
+ out[k] = v !== null && typeof v === 'object' && !Array.isArray(v)
173
+ && base?.[k] !== null && typeof base?.[k] === 'object'
174
+ ? deepMerge(base[k], v)
175
+ : v
176
+ }
177
+ return out
178
+ }
179
+
180
+ let cachedConfig = null
181
+ let cachedMtimeMs = -1
182
+ async function loadVoiceConfig() {
183
+ // [2026-08-22] 实时读取:配置文件 mtime 变化(保存/外部修改)即重读,杜绝进程内旧缓存
184
+ let mtimeMs = -1
185
+ try { mtimeMs = (await stat(CONFIG_PATH)).mtimeMs } catch { /* 文件不存在 */ }
186
+ if (cachedConfig !== null && mtimeMs === cachedMtimeMs) return cachedConfig
187
+ let parsed = {}
188
+ try {
189
+ parsed = JSON.parse(await readFile(CONFIG_PATH, 'utf8'))
190
+ } catch { /* 首次无配置 */ }
191
+ cachedConfig = deepMerge(defaultVoiceConfig(), parsed)
192
+ cachedMtimeMs = mtimeMs
193
+ // [0.3.4] 自带素材初始化(拷贝克隆样本 + 首次安装自动注册小团团)
194
+ await ensureBundledAssets(cachedConfig, parsed)
195
+ // 环境变量覆盖(兼容旧配置;显式配置值优先于 env)
196
+ const env = process.env
197
+ if (env.TTS_XIAOMI_KEY !== undefined && cachedConfig.engines.xiaomi.apiKey === '') cachedConfig.engines.xiaomi.apiKey = env.TTS_XIAOMI_KEY
198
+ if (env.TTS_XIAOMI_VOICE !== undefined && cachedConfig.engines.xiaomi.voice === '冰糖') cachedConfig.engines.xiaomi.voice = env.TTS_XIAOMI_VOICE
199
+ if (env.TTS_XIAOMI_BASE_URL !== undefined) cachedConfig.engines.xiaomi.baseUrl = env.TTS_XIAOMI_BASE_URL
200
+ if (env.TTS_EDGE_VOICE !== undefined && cachedConfig.engines.edge.voice === 'zh-CN-XiaoxiaoNeural') cachedConfig.engines.edge.voice = env.TTS_EDGE_VOICE
201
+ if (env.DSH_LOCAL_TTS_CMD !== undefined && cachedConfig.engines.local.cmd === '') cachedConfig.engines.local.cmd = env.DSH_LOCAL_TTS_CMD
202
+ return cachedConfig
203
+ }
204
+
205
+ /** 同步读配置:供 defineTool 的 description 等同步上下文使用
206
+ * (注意:loadVoiceConfig 是 async,在同步处直接用会拿到 Promise → 字段全 undefined)。 */
207
+ function loadVoiceConfigSync() {
208
+ if (cachedConfig !== null) return cachedConfig
209
+ let parsed = {}
210
+ try {
211
+ parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
212
+ } catch { /* 首次无配置 */ }
213
+ return deepMerge(defaultVoiceConfig(), parsed)
214
+ }
215
+
216
+ async function saveVoiceConfig(config) {
217
+ cachedConfig = deepMerge(defaultVoiceConfig(), config)
218
+ await mkdir(join(CONFIG_PATH, '..'), { recursive: true })
219
+ await writeFile(CONFIG_PATH, JSON.stringify(cachedConfig, null, 2), 'utf8')
220
+ try { cachedMtimeMs = (await stat(CONFIG_PATH)).mtimeMs } catch { /* 忽略 */ }
221
+ return cachedConfig
222
+ }
223
+
224
+ // ──────────────────────────────────────────────────────────────
225
+ // 语音对象存储(内容寻址,与图片附件同池:DSH_HOME/attachments/v1/objects)
226
+ // ──────────────────────────────────────────────────────────────
227
+ const MAX_VOICE_BYTES = 25 * 1024 * 1024
228
+
229
+ // [2026-08-21] 语音气泡(聊天界面 DOM 注入):录音暂存 DSH_HOME/voice-outbox/
230
+ const VOICE_OUTBOX_EXT = {
231
+ 'audio/webm': 'webm',
232
+ 'audio/ogg': 'ogg',
233
+ 'audio/mp4': 'mp4',
234
+ 'audio/m4a': 'm4a',
235
+ 'audio/wav': 'wav',
236
+ 'audio/mpeg': 'mp3',
237
+ }
238
+ const VOICE_OUTBOX_MIME = {
239
+ webm: 'audio/webm', ogg: 'audio/ogg', mp4: 'audio/mp4', m4a: 'audio/m4a',
240
+ wav: 'audio/wav', mp3: 'audio/mpeg',
241
+ }
242
+
243
+ /** 检测当前 dshconnection 契约是否原生支持 voice content(rc.8 本地改造有;npm 官方版无)。
244
+ * 优先从 dsh 进程实际运行的位置解析(dev 仓库 cwd / npm 全局),避免误报。 */
245
+ /** 从音频文件头嗅探媒体类型(对象存储无扩展名,TTS 输出可能是 wav/mp3)。 */
246
+ function sniffAudioType(bytes) {
247
+ if (bytes.length >= 4 && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) return 'audio/wav'
248
+ if (bytes.length >= 3 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) return 'audio/mpeg'
249
+ if (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) return 'audio/mpeg'
250
+ if (bytes.length >= 4 && bytes[0] === 0x4f && bytes[1] === 0x67 && bytes[2] === 0x67 && bytes[3] === 0x53) return 'audio/ogg'
251
+ return 'audio/mpeg'
252
+ }
253
+
254
+ async function detectVoiceContractSupport() {
255
+ const markers = ['literal("voice")', "literal('voice')"]
256
+ const containsVoice = (s) => markers.some((m) => s.includes(m))
257
+ // 1) dev 仓库(本机 lecoo:dsh 由 apps/cli tsx 直接跑,cwd=仓库根)
258
+ for (const rel of [
259
+ join('packages', 'client', 'connection', 'lib', 'client.js'),
260
+ join('node_modules', '@deepseek-ai', 'dsh-client-connection', 'lib', 'client.js'),
261
+ ]) {
262
+ try {
263
+ if (containsVoice(readFileSync(join(process.cwd(), rel), 'utf8'))) return true
264
+ } catch { /* 下一个候选 */ }
265
+ }
266
+ // 2) npm 安装(XDN:dsh 在全局 node_modules,从插件解析链向上找)
267
+ try {
268
+ const { createRequire } = await import('node:module')
269
+ const require = createRequire(import.meta.url)
270
+ const p = require.resolve('@deepseek-ai/dsh-client-connection/lib/client.js')
271
+ if (containsVoice(readFileSync(p, 'utf8'))) return true
272
+ } catch { /* 找不到 */ }
273
+ return false
274
+ }
275
+
276
+ function voiceStorageRoot() {
277
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
278
+ return resolve(join(home, 'attachments', 'v1'))
279
+ }
280
+
281
+ function objectPath(root, sha256) {
282
+ return join(root, 'objects', sha256.slice(0, 2), sha256)
283
+ }
284
+
285
+ async function saveVoiceFile(root, data, mediaType, durationMs) {
286
+ if (data.byteLength > MAX_VOICE_BYTES) {
287
+ throw new Error(`Voice object exceeds the ${MAX_VOICE_BYTES}-byte limit.`)
288
+ }
289
+ const sha256 = createHash('sha256').update(data).digest('hex')
290
+ const bucket = join(root, 'objects', sha256.slice(0, 2))
291
+ const target = objectPath(root, sha256)
292
+ await mkdir(bucket, { recursive: true, mode: 0o700 })
293
+ let handle
294
+ try {
295
+ handle = await open(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
296
+ await handle.writeFile(data)
297
+ await handle.close()
298
+ handle = undefined
299
+ } catch (error) {
300
+ if (handle !== undefined) await handle.close().catch(() => {})
301
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) {
302
+ throw new Error(`Unable to persist voice object: ${String(error)}`, { cause: error })
303
+ }
304
+ }
305
+ return {
306
+ voiceId: `sha256:${sha256}`,
307
+ mediaType,
308
+ bytes: data.byteLength,
309
+ ...(durationMs === undefined ? {} : { durationMs }),
310
+ }
311
+ }
312
+
313
+ // ──────────────────────────────────────────────────────────────
314
+ // TTS 引擎
315
+ // ──────────────────────────────────────────────────────────────
316
+ /** 解析 ffmpeg 可执行文件:环境变量显式指定 > PATH 探测(where/which)> 兜底已知安装位置。
317
+ * [本地改造 2026-08-21] 修复:原来硬编码本机路径,换机器必挂。 */
318
+ function resolveFfmpegBin() {
319
+ if (typeof process.env.DSH_VOICE_FFMPEG_BIN === 'string' && process.env.DSH_VOICE_FFMPEG_BIN.trim() !== '') {
320
+ return process.env.DSH_VOICE_FFMPEG_BIN.trim()
321
+ }
322
+ try {
323
+ // Windows: where ffmpeg;POSIX: which ffmpeg
324
+ const probe = process.platform === 'win32' ? 'where' : 'which'
325
+ const out = execFileSync(probe, ['ffmpeg'], { windowsHide: true, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] })
326
+ const first = out.split(/\r?\n/).map((l) => l.trim()).find((l) => l !== '')
327
+ if (first !== undefined) return first
328
+ } catch { /* 不在 PATH */ }
329
+ return 'C:\\Users\\oadan\\AppData\\Local\\Microsoft\\WinGet\\Links\\ffmpeg.exe'
330
+ }
331
+ const FFMPEG_BIN = resolveFfmpegBin()
332
+
333
+ /** 统一入口:provider 引擎;auto 配置 defaultEngine,失败沿降级链(最后兜底微软 edge)。
334
+ * voiceDesc 为动态音色描述(仅 voicedesign 用):AI 对话中生成,覆盖配置里的默认音色描述。
335
+ * [本地改造 2026-08-21] 克隆不再隐式优先:默认克隆由「默认语音引擎=voiceclone」控制,或显式 provider=voiceclone。
336
+ * [2026-08-22] overrideVoice=true:固定模式(示例/自定义)下 voiceDesc 整体替换底嗓(用户明确要求换声);默认 false=voiceDesc 作为情绪/风格叠加在底嗓上。 */
337
+ async function synthesizeReplyVoice(text, provider, voiceDesc, overrideVoice) {
338
+ const cfg = await loadVoiceConfig()
339
+ const speak = stripMarkdown(text)
340
+ const engine = provider ?? cfg.defaultEngine ?? 'auto'
341
+ // 兜底链:首选 defaultEngine(若合理),否则直接微软 edge(免费,无需 key)
342
+ const preferred = cfg.defaultEngine !== undefined && cfg.defaultEngine !== 'auto' ? cfg.defaultEngine : 'edge'
343
+ const fallbackChain = engine === 'auto'
344
+ ? [preferred, 'edge']
345
+ : [engine, 'edge']
346
+ // [本地改造 2026-08-21] 克隆不再隐式优先:只有 defaultEngine=voiceclone(或显式 provider=voiceclone)
347
+ // 才走克隆(synthesizeEngine voiceclone 分支),其余情况走正常引擎链。
348
+ for (const candidate of fallbackChain) {
349
+ try {
350
+ const audio = await synthesizeEngine(candidate, speak, cfg, voiceDesc, overrideVoice)
351
+ if (audio !== null) return audio
352
+ } catch { /* 尝试下一个 */ }
353
+ }
354
+ // 最终兜底:微软 edge(免费无需 key),无视 enabled 开关——保证 4 个服务商都未启用时也有声音
355
+ try {
356
+ const audio = await synthesizeEdgeVoice(speak, cfg.engines.edge)
357
+ if (audio !== null) return audio
358
+ } catch { /* 忽略 */ }
359
+ return null
360
+ }
361
+
362
+ async function synthesizeEngine(engine, text, cfg, voiceDesc, overrideVoice) {
363
+ const e = cfg.engines[engine]
364
+ // [本地改造 2026-08-21] 配置存在即启用:设置页已去复选框,enabled 不再拦截;
365
+ // 各引擎自身检查必需参数(xiaomi/ali key、local 查 cmd/url、voicedesign 查 key+desc、voiceclone 查 key+样本)。
366
+ if (e === undefined) return null
367
+ switch (engine) {
368
+ case 'edge': return synthesizeEdgeVoice(text, e)
369
+ case 'xiaomi': return synthesizeXiaomiVoice(text, e)
370
+ case 'voicedesign': return synthesizeXiaomiVoiceDesign(text, e, cfg, voiceDesc, overrideVoice)
371
+ case 'voiceclone': return synthesizeXiaomiVoiceClone(text, e, cfg, voiceDesc)
372
+ case 'local': return synthesizeLocalVoice(text, e)
373
+ case 'ali': return synthesizeAliVoice(text, e)
374
+ default: return null
375
+ }
376
+ }
377
+
378
+ // ── edge 微软免费 ──
379
+ async function synthesizeEdgeVoice(text, cfg) {
380
+ const voice = cfg?.voice ?? 'zh-CN-XiaoxiaoNeural'
381
+ const mp3 = await edgeTts(text, voice)
382
+ return toMp3(new Uint8Array(mp3), 'audio/mpeg')
383
+ }
384
+
385
+ // ── xiaomi 小米预置音色(mimo-v2.5-tts)──
386
+ async function synthesizeXiaomiVoice(text, cfg) {
387
+ const apiKey = cfg?.apiKey ?? ''
388
+ if (apiKey === '') return null
389
+ const baseUrl = cfg?.baseUrl ?? 'https://api.xiaomimimo.com/v1'
390
+ const voice = cfg?.voice ?? '冰糖'
391
+ let speak = text
392
+ // 唱歌:文本自带 (唱歌) 标签,或明确唱歌意图(唱/歌声)时自动加标签
393
+ const hasTag = /^\s*\((唱歌|sing|singing)\)/i.test(speak)
394
+ const wantsSing = !hasTag && /(唱(歌|一?首|一段)|歌声回复|用歌声|唱歌回|来一段|唱两句)/i.test(speak)
395
+ if (wantsSing) speak = `(唱歌)${speak}`
396
+ const messages = []
397
+ if (cfg?.context?.trim() !== '') messages.push({ role: 'user', content: cfg.context.trim() })
398
+ messages.push({ role: 'assistant', content: speak })
399
+ const response = await fetch(`${baseUrl}/chat/completions`, {
400
+ method: 'POST',
401
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
402
+ body: JSON.stringify({
403
+ model: 'mimo-v2.5-tts',
404
+ messages,
405
+ max_tokens: 8192,
406
+ audio: { format: 'wav', voice },
407
+ }),
408
+ })
409
+ if (!response.ok) return null
410
+ const payload = await response.json()
411
+ const data = payload?.choices?.[0]?.message?.audio?.data
412
+ if (typeof data !== 'string' || data.length < 100) return null
413
+ return toMp3(new Uint8Array(Buffer.from(data, 'base64')), 'audio/wav')
414
+ }
415
+
416
+ // ── xiaomi 音色设计(mimo-v2.5-tts-voicedesign:user=音色描述,无 voice)──
417
+ // [2026-08-22] overrideVoice=true:固定模式(示例/自定义)下 voiceDesc 整体替换底嗓(用户明确要求换声);
418
+ // 默认 false:voiceDesc 作为"情绪/风格"叠加在用户设置的底嗓(context)后面——与工具描述一致,不再"非空即覆盖"。
419
+ async function synthesizeXiaomiVoiceDesign(text, cfg, globalCfg, voiceDesc, overrideVoice) {
420
+ const apiKey = globalCfg.engines.xiaomi.apiKey
421
+ // 优先用 AI 动态生成的音色描述(voiceDesc),否则用配置里的默认音色描述
422
+ // [2026-08-22] 模式感知兜底:mode=ai 时绝不能回退到用户残留的固定描述(context)——
423
+ // 而是按 aiGender/aiAge 生成中性基座(用户没让 AI 写时也稳定),避免"切到 AI 模式却用旧 ASMR 指令"。
424
+ const vdMode = cfg?.mode
425
+ let desc = (voiceDesc ?? '').trim()
426
+ if (vdMode === 'ai') {
427
+ // [2026-08-22] AI 模式:身份一律以用户实时配置的锚点为准(锁定项)。
428
+ // 修复(XDN 实测): ①"老年感+女孩"分维度拼接→身份自相矛盾(模型选"女孩"→萝莉化),
429
+ // 改 ageGenderIdentity 合并成"老年女性/小女孩/少女"等无歧义短语;
430
+ // ②"性别/年龄表述忽略"注把 AI 写的"沙哑/苍老/低沉"等最强质感词删了,
431
+ // 改为只锁定性别/年龄, 允许情绪与音色质感词保留并强化。
432
+ const gKey = cfg?.aiGender === 'male' ? 'male' : cfg?.aiGender === 'female' ? 'female' : ''
433
+ const aKey = AI_AGE_LABELS[cfg?.aiAge] !== undefined ? cfg.aiAge : ''
434
+ const identity = ageGenderIdentity(aKey, gKey)
435
+ const lockG = cfg?.lockGender === true
436
+ const lockA = cfg?.lockAge === true
437
+ const lockT = cfg?.lockTimbre === true
438
+ const gLabel = gKey === 'male' ? '男' : gKey === 'female' ? '女' : ''
439
+ const aLabel = AI_AGE_LABELS[aKey] ?? ''
440
+ const anchorText = [
441
+ lockG ? '性别固定为' + (gLabel !== '' ? gLabel : '每次一致') : '',
442
+ lockA ? '年龄感固定为' + (aLabel !== '' ? aLabel : '每次一致') : '',
443
+ lockT ? '音色质感保持稳定' : '',
444
+ ].filter(Boolean).join('、')
445
+ if (identity !== '' || anchorText !== '') {
446
+ desc = (identity !== '' ? '一位' + identity + '的声音(身份硬性要求:' + (anchorText !== '' ? anchorText : '按上述身份')
447
+ + ';若与其他描述冲突,一律以本身份为准)。' : '')
448
+ + (desc !== '' ? '语气/情绪要求:' + desc + '(性别/年龄以身份为准;音色质感与语气情绪按本描述执行——如"沙哑、苍老、低沉、气声"等质感词应保留并强化)。'
449
+ : '语气情绪要饱满生动:像真人一样带喜怒哀乐、笑音、撒娇或急切等起伏,禁止平淡。')
450
+ } else if (desc === '') {
451
+ desc = '语气情绪要饱满生动:像真人一样带喜怒哀乐、笑音、撒娇或急切等起伏,禁止平淡。'
452
+ }
453
+ } else {
454
+ // 固定模式(示例/自定义):底嗓一律用用户设置的 context,voiceDesc 作为情绪/风格叠加在后面(描述与实现一致);
455
+ // 仅 overrideVoice=true(用户明确要求换一种完全不同的声音)时整体替换。
456
+ const base = (cfg?.context?.trim() ?? '')
457
+ if (overrideVoice === true && desc !== '') {
458
+ desc = desc // 整体替换底嗓
459
+ } else {
460
+ desc = base + (desc !== '' ? ';' + desc : '')
461
+ }
462
+ }
463
+ if (apiKey === '' || desc === '') return null
464
+ const baseUrl = globalCfg.engines.xiaomi.baseUrl ?? 'https://api.xiaomimimo.com/v1'
465
+ const messages = [
466
+ { role: 'user', content: desc },
467
+ { role: 'assistant', content: text },
468
+ ]
469
+ const response = await fetch(`${baseUrl}/chat/completions`, {
470
+ method: 'POST',
471
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
472
+ body: JSON.stringify({
473
+ model: 'mimo-v2.5-tts-voicedesign',
474
+ messages,
475
+ max_tokens: 8192,
476
+ audio: { format: 'wav' },
477
+ }),
478
+ })
479
+ if (!response.ok) return null
480
+ const payload = await response.json()
481
+ const data = payload?.choices?.[0]?.message?.audio?.data
482
+ if (typeof data !== 'string' || data.length < 100) return null
483
+ return toMp3(new Uint8Array(Buffer.from(data, 'base64')), 'audio/wav')
484
+ }
485
+
486
+ // ── xiaomi 音色克隆(mimo-v2.5-tts-voiceclone:audio.voice=样本 dataURL,≤10MB)──
487
+ // samples: [{id,name,path}] 支持多个克隆音色;兼容旧 samplePath
488
+ // voiceDesc 为情感/风格指令(AI 生成,如"委屈撒娇"):优先于 cfg.context,让克隆底嗓带情绪
489
+ async function synthesizeXiaomiVoiceClone(text, cfg, globalCfg, voiceDesc) {
490
+ const apiKey = globalCfg.engines.xiaomi.apiKey
491
+ const samplePath = (Array.isArray(cfg?.samples) && cfg.samples.length > 0 && typeof cfg.samples[0]?.path === 'string' && cfg.samples[0].path !== '')
492
+ ? cfg.samples[0].path
493
+ : (cfg?.samplePath ?? '')
494
+ if (apiKey === '' || samplePath === '') return null
495
+ const baseUrl = globalCfg.engines.xiaomi.baseUrl ?? 'https://api.xiaomimimo.com/v1'
496
+ let sample
497
+ try {
498
+ const bytes = await readFile(samplePath)
499
+ if (bytes.byteLength > 10 * 1024 * 1024) return null
500
+ const suffix = samplePath.toLowerCase().split('.').pop()
501
+ const mime = suffix === 'mp3' ? 'audio/mpeg' : suffix === 'wav' ? 'audio/wav' : 'audio/wav'
502
+ sample = `data:${mime};base64,${bytes.toString('base64')}`
503
+ } catch { return null }
504
+ const messages = []
505
+ // [本地改造 2026-08-21] 风格指令优先级:voiceDesc(AI 生成)> 样本自带 context(每个克隆音色自己的性格)> 全局 context(兜底)
506
+ const firstSample = Array.isArray(cfg?.samples) ? cfg.samples[0] : undefined
507
+ const sampleContext = typeof firstSample?.context === 'string' ? firstSample.context.trim() : ''
508
+ const styleInstruct = (voiceDesc ?? '').trim() !== ''
509
+ ? voiceDesc.trim()
510
+ : (sampleContext !== '' ? sampleContext : (cfg?.context?.trim() ?? ''))
511
+ if (styleInstruct !== '') messages.push({ role: 'user', content: styleInstruct })
512
+ messages.push({ role: 'assistant', content: text })
513
+ const response = await fetch(`${baseUrl}/chat/completions`, {
514
+ method: 'POST',
515
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
516
+ body: JSON.stringify({
517
+ model: 'mimo-v2.5-tts-voiceclone',
518
+ messages,
519
+ max_tokens: 8192,
520
+ audio: { format: 'wav', voice: sample },
521
+ }),
522
+ })
523
+ if (!response.ok) return null
524
+ const payload = await response.json()
525
+ const data = payload?.choices?.[0]?.message?.audio?.data
526
+ if (typeof data !== 'string' || data.length < 100) return null
527
+ return toMp3(new Uint8Array(Buffer.from(data, 'base64')), 'audio/wav')
528
+ }
529
+
530
+ // [2026-08-22] 解析 Windows 命令行参数(正确处理双引号:引号内空格不拆、剥掉引号)。
531
+ // 之前用 command.split(/\s+/) 拆参数,用户填的带引号路径会被整段含引号传给
532
+ // execFileSync → node 把 "C:\...\local-tts.mjs" 当成相对路径拼上 cwd →
533
+ // Cannot find module 'D:\opt\...\"C:\Users\...'。本函数根治该问题。
534
+ function splitCommandLine(cmd) {
535
+ const args = []
536
+ let cur = ''
537
+ let inQuote = false
538
+ for (let i = 0; i < cmd.length; i++) {
539
+ const ch = cmd[i]
540
+ if (ch === '"') {
541
+ inQuote = !inQuote
542
+ } else if (ch === ' ' || ch === '\t') {
543
+ if (inQuote) { cur += ch } else if (cur !== '') { args.push(cur); cur = '' }
544
+ } else {
545
+ cur += ch
546
+ }
547
+ }
548
+ if (cur !== '') args.push(cur)
549
+ return args
550
+ }
551
+
552
+ // ── local 本地 MeloTTS:HTTP 常驻服务优先,CMD 兜底 ──
553
+ async function synthesizeLocalVoice(text, cfg) {
554
+ const url = cfg?.url?.trim() ?? ''
555
+ if (url !== '') {
556
+ const response = await fetch(url, {
557
+ method: 'POST',
558
+ headers: { 'Content-Type': 'application/json' },
559
+ body: JSON.stringify({ text }),
560
+ timeout: 60_000,
561
+ })
562
+ if (!response.ok) return null
563
+ const body = await response.arrayBuffer()
564
+ return toMp3(new Uint8Array(body), 'audio/wav')
565
+ }
566
+ const command = cfg?.cmd?.trim() ?? ''
567
+ if (command === '') return null
568
+ const parts = splitCommandLine(command)
569
+ const bin = parts[0]
570
+ if (bin === undefined) return null
571
+ const rest = parts.slice(1)
572
+ const audio = execFileSync(bin, [...rest, text], {
573
+ windowsHide: true,
574
+ encoding: 'buffer',
575
+ timeout: 60_000,
576
+ })
577
+ return toMp3(new Uint8Array(audio), 'audio/mpeg')
578
+ }
579
+
580
+ // ── ali 阿里 qwen3-tts-flash(dashscope)──
581
+ async function synthesizeAliVoice(text, cfg) {
582
+ const apiKey = cfg?.apiKey ?? ''
583
+ if (apiKey === '') return null
584
+ const baseUrl = cfg?.baseUrl ?? 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation'
585
+ const voice = cfg?.voice ?? 'Cherry'
586
+ const response = await fetch(baseUrl, {
587
+ method: 'POST',
588
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
589
+ body: JSON.stringify({
590
+ model: 'qwen3-tts-flash',
591
+ input: { text },
592
+ parameters: { voice, format: 'wav', language_type: 'zh' },
593
+ }),
594
+ timeout: 60_000,
595
+ })
596
+ if (!response.ok) return null
597
+ const payload = await response.json()
598
+ const audioUrl = payload?.output?.audio?.url
599
+ if (typeof audioUrl !== 'string' || audioUrl === '') return null
600
+ const audioRes = await fetch(audioUrl, { timeout: 120_000 })
601
+ if (!audioRes.ok) return null
602
+ const body = await audioRes.arrayBuffer()
603
+ return toMp3(new Uint8Array(body), 'audio/wav')
604
+ }
605
+
606
+ // ──────────────────────────────────────────────────────────────
607
+ // ASR 语音识别(三模式:service=本地常驻HTTP / cmd=本地命令 / api=在线API)
608
+ // ──────────────────────────────────────────────────────────────
609
+ /** 把 base64 音频写入临时 wav,调用本地 sherpa 常驻服务(POST /transcribe {audioPath})或命令。 */
610
+ async function transcribeAudio(base64Audio, cfg) {
611
+ const asr = cfg?.engines?.asr
612
+ if (asr === undefined || asr.enabled === false) return { ok: false, error: 'ASR 未启用' }
613
+ if (typeof base64Audio !== 'string' || base64Audio === '') return { ok: false, error: '缺少音频数据' }
614
+ const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-asr-${randomUUID()}.raw`)
615
+ const tmpWav = join(process.env.TEMP ?? '/tmp', `dsh-asr-${randomUUID()}.wav`)
616
+ await writeFile(tmpIn, Buffer.from(base64Audio, 'base64'))
617
+ try {
618
+ // 统一转成 16kHz 单声道 PCM WAV(录音是 webm/其他容器,sherpa 只认标准 wav)
619
+ let wavPath = tmpIn
620
+ try {
621
+ execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', tmpWav], {
622
+ windowsHide: true, stdio: 'ignore', timeout: 30_000,
623
+ })
624
+ wavPath = tmpWav
625
+ // 缓存最近一次录音到 ~/.dsh/last-voice.wav(供"用我刚才那段语音克隆音色"使用)
626
+ try {
627
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
628
+ await mkdir(homeDir, { recursive: true })
629
+ await writeFile(join(homeDir, 'last-voice.wav'), await readFile(tmpWav))
630
+ } catch { /* 缓存失败不影响识别 */ }
631
+ } catch { /* ffmpeg 失败则用原始文件(可能已是 wav) */ }
632
+ // 1) 本地常驻服务(nssm: asr,端口 18790;POST /transcribe {audioPath})
633
+ if (asr.mode === 'service' && (asr.url ?? '').trim() !== '') {
634
+ const baseUrl = asr.url.trim().replace(/\/+$/, '')
635
+ const response = await fetch(`${baseUrl}/transcribe`, {
636
+ method: 'POST',
637
+ headers: { 'Content-Type': 'application/json' },
638
+ body: JSON.stringify({ audioPath: wavPath }),
639
+ timeout: 60_000,
640
+ })
641
+ if (!response.ok) return { ok: false, error: `ASR 服务返回 ${response.status}` }
642
+ const payload = await response.json().catch(() => ({}))
643
+ const text = typeof payload?.text === 'string' ? payload.text.trim() : ''
644
+ if (text === '') return { ok: false, error: 'ASR 服务未返回文本' }
645
+ return { ok: true, text }
646
+ }
647
+ // 2) 本地命令(sherpa-onnx-offline.exe,结果输出到 stderr,需合并双流解析)
648
+ if (asr.mode === 'cmd' && (asr.cmd ?? '').trim() !== '') {
649
+ const parts = splitCommandLine(asr.cmd.trim())
650
+ const bin = parts[0]
651
+ if (bin === undefined) return { ok: false, error: '命令格式错误' }
652
+ const { spawnSync } = await import('node:child_process')
653
+ const result = spawnSync(bin, [...parts.slice(1), wavPath], {
654
+ windowsHide: true,
655
+ encoding: 'utf-8',
656
+ timeout: 60_000,
657
+ stdio: ['ignore', 'pipe', 'pipe'],
658
+ })
659
+ // sherpa-onnx 把结果打印到 stderr(stdout 部分版本也有),合并解析
660
+ const all = (result.stdout ?? '') + '\n' + (result.stderr ?? '')
661
+ const m = all.match(/"text"\s*:\s*"([^"]*)"/)
662
+ const text = (m?.[1] ?? '').trim()
663
+ if (text === '') return { ok: false, error: '本地命令未输出识别结果' }
664
+ return { ok: true, text }
665
+ }
666
+ // 3) 在线 API(默认小米 mimo-v2.5-asr:OpenAI 兼容 chat/completions + input_audio base64;
667
+ // 若 apiBaseUrl openai 则走 Whisper 风格 /audio/transcriptions)
668
+ if (asr.mode === 'api' && (asr.apiKey ?? '').trim() !== '') {
669
+ const apiKey = asr.apiKey.trim()
670
+ const baseUrl = (asr.apiBaseUrl ?? 'https://api.xiaomimimo.com/v1').replace(/\/+$/, '')
671
+ const audioBase64 = Buffer.from(base64Audio, 'base64').toString('base64')
672
+ if (baseUrl.includes('openai')) {
673
+ // Whisper 兼容(multipart file + model)
674
+ const form = new FormData()
675
+ const blob = new Blob([Buffer.from(audioBase64, 'base64')], { type: 'audio/wav' })
676
+ form.append('file', blob, 'audio.wav')
677
+ form.append('model', 'whisper-1')
678
+ const response = await fetch(`${baseUrl}/audio/transcriptions`, {
679
+ method: 'POST',
680
+ headers: { Authorization: `Bearer ${apiKey}` },
681
+ body: form,
682
+ timeout: 60_000,
683
+ })
684
+ if (!response.ok) return { ok: false, error: `ASR API 返回 ${response.status}` }
685
+ const payload = await response.json().catch(() => ({}))
686
+ const text = typeof payload?.text === 'string' ? payload.text.trim() : ''
687
+ if (text === '') return { ok: false, error: 'ASR API 未返回文本' }
688
+ return { ok: true, text }
689
+ }
690
+ // 小米 mimo-v2.5-asr:chat/completions + input_audio dataURL
691
+ const response = await fetch(`${baseUrl}/chat/completions`, {
692
+ method: 'POST',
693
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
694
+ body: JSON.stringify({
695
+ model: 'mimo-v2.5-asr',
696
+ messages: [
697
+ {
698
+ role: 'user',
699
+ content: [
700
+ { type: 'input_audio', input_audio: { data: `data:audio/wav;base64,${audioBase64}` } },
701
+ ],
702
+ },
703
+ ],
704
+ extra_body: { asr_options: { language: 'auto' } },
705
+ }),
706
+ timeout: 120_000,
707
+ })
708
+ if (!response.ok) return { ok: false, error: `小米 ASR 返回 ${response.status}` }
709
+ const payload = await response.json().catch(() => ({}))
710
+ const text = typeof payload?.choices?.[0]?.message?.content === 'string'
711
+ ? payload.choices[0].message.content.trim()
712
+ : ''
713
+ if (text === '') return { ok: false, error: '小米 ASR 未返回文本' }
714
+ return { ok: true, text }
715
+ }
716
+ return { ok: false, error: 'ASR 未配置(服务地址/命令/API Key 三选一)' }
717
+ } catch (error) {
718
+ return { ok: false, error: error instanceof Error ? error.message : 'ASR 转写失败' }
719
+ } finally {
720
+ await unlink(tmpWav).catch(() => {})
721
+ await unlink(tmpIn).catch(() => {})
722
+ }
723
+ }
724
+
725
+ // ──────────────────────────────────────────────────────────────
726
+ // 音频工具
727
+ // ──────────────────────────────────────────────────────────────
728
+ async function toMp3(data, declared) {
729
+ const isMp3 = data.length > 2 && data[0] === 0xFF && ((data[1] ?? 0) & 0xE0) === 0xE0
730
+ let finalData = data
731
+ let mediaType = declared
732
+ if (!isMp3) {
733
+ const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-tts-in-${randomUUID()}.wav`)
734
+ const mp3Path = join(process.env.TEMP ?? '/tmp', `dsh-tts-${randomUUID()}.mp3`)
735
+ await writeFile(tmpIn, data)
736
+ try {
737
+ execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-c:a', 'libmp3lame', '-b:a', '128k', mp3Path], {
738
+ windowsHide: true, stdio: 'ignore', timeout: 30_000,
739
+ })
740
+ finalData = new Uint8Array(await readFile(mp3Path))
741
+ mediaType = 'audio/mpeg'
742
+ } catch {
743
+ // 转码失败保留原容器(部分浏览器仍可播)。
744
+ } finally {
745
+ await unlink(tmpIn).catch(() => {})
746
+ await unlink(mp3Path).catch(() => {})
747
+ }
748
+ }
749
+ const durationMs = estimateAudioDurationMs(finalData)
750
+ return {
751
+ mediaType,
752
+ data: finalData,
753
+ ...(durationMs === undefined ? {} : { durationMs }),
754
+ }
755
+ }
756
+
757
+ function looksLikeOgg(data) {
758
+ return data.length >= 4
759
+ && data[0] === 0x4F && data[1] === 0x67 && data[2] === 0x67 && data[3] === 0x53
760
+ }
761
+
762
+ function stripMarkdown(text) {
763
+ return text
764
+ .replace(/```[\s\S]*?```/g, ' ')
765
+ .replace(/`([^`]+)`/g, '$1')
766
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
767
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
768
+ .replace(/^#{1,6}\s*/gm, '')
769
+ .replace(/^>+\s*/gm, '')
770
+ .replace(/^\s*[-*+]\s+/gm, '')
771
+ .replace(/^\s*\d+[.)]\s+/gm, '')
772
+ .replace(/^\s*\|?[\s:|-]+\|?\s*$/gm, '')
773
+ .replace(/^[-*_]{3,}\s*$/gm, '')
774
+ .replace(/\|/g, ' ')
775
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
776
+ .replace(/\*([^*]+)\*/g, '$1')
777
+ .replace(/__([^_]+)__/g, '$1')
778
+ .replace(/_([^_]+)_/g, '$1')
779
+ .replace(/~~([^~]+)~~/g, '$1')
780
+ .replace(/^\s*[-*_]\s*$/gm, '')
781
+ .replace(/\s*\n\s*/g, ' ')
782
+ .replace(/\s{2,}/g, ' ')
783
+ .trim()
784
+ }
785
+
786
+ function estimateAudioDurationMs(data) {
787
+ if (looksLikeOgg(data)) {
788
+ const kbps = 48
789
+ return Math.round(data.length / (kbps * 1000 / 8) * 1000)
790
+ }
791
+ let offset = 0
792
+ if (data.length >= 10 && (data[0] ?? 0) === 0x49 && (data[1] ?? 0) === 0x44 && (data[2] ?? 0) === 0x33
793
+ && ((data[3] ?? 0) & 0xFF) < 0xFF && ((data[4] ?? 0) & 0xFF) < 0xFF) {
794
+ const size = (((data[6] ?? 0) & 0x7F) << 21) | (((data[7] ?? 0) & 0x7F) << 14)
795
+ | (((data[8] ?? 0) & 0x7F) << 7) | ((data[9] ?? 0) & 0x7F)
796
+ offset = 10 + size
797
+ }
798
+ while (offset + 4 <= data.length) {
799
+ const sync = ((data[offset] ?? 0) << 8) | (data[offset + 1] ?? 0)
800
+ if ((sync & 0xFFE0) === 0xFFE0) {
801
+ const bitrateIndex = ((data[offset + 2] ?? 0) >>> 4) & 0x0F
802
+ const sampleRateIndex = ((data[offset + 2] ?? 0) >>> 2) & 0x03
803
+ if (bitrateIndex === 0 || bitrateIndex === 15 || sampleRateIndex === 3) return undefined
804
+ const bitrates = [32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]
805
+ const kbps = bitrates[bitrateIndex - 1] ?? 128
806
+ return Math.round((data.length - offset) / (kbps * 1000 / 8) * 1000)
807
+ }
808
+ offset += 1
809
+ }
810
+ return undefined
811
+ }
812
+
813
+ // ──────────────────────────────────────────────────────────────
814
+ // 自动语音回复辅助
815
+ // ──────────────────────────────────────────────────────────────
816
+ /** 用户文本是否明确要求语音回复;返回要用的 TTS provider,否则 null。 */
817
+ function voiceRequestProvider(text) {
818
+ if (!/(用语音回|回个语音|发个语音|发语音|用语音说|语音回我|语音告诉我|念给我|语音播报|用小米|用微软|小米语音|微软语音|xiaomi|edge语音|语音回复我)/i.test(text)) return null
819
+ if (/小米|xiaomi/i.test(text)) return 'xiaomi'
820
+ if (/微软|edge/i.test(text)) return 'edge'
821
+ if (/阿里|ali/i.test(text)) return 'ali'
822
+ return 'auto'
823
+ }
824
+
825
+ /** 判断一行是否像代码/噪声,不该被念出来(中文口语基本不会命中这些模式)。 */
826
+ function looksLikeCodeLine(t) {
827
+ if (/[=;{}<>$|]/.test(t)) return true // 赋值/分号/花括号/尖括号/管道/美元
828
+ if (/=>|::/.test(t)) return true // 箭头函数/作用域
829
+ if (/\b[a-z_]\w{2,}\s*\(/.test(t)) return true // 函数调用 foo(
830
+ if (/\.\w+(\s*\(|\s*=)/.test(t)) return true // 方法链 obj.method(
831
+ if (/^\s*(const|let|var|function|def|class|import|export|return|if|for|while|public|private|async|await|SELECT|INSERT|UPDATE|FROM|WHERE|npm|npx|pip|cd|ls|git|sudo|curl|wget|docker|kubectl|python|node|tsx|pnpm|yarn|bun|cargo)\b/i.test(t)) return true
832
+ if (/[\\/][\w.-]+\.\w{1,5}/.test(t)) return true // 文件路径 c:\x.js / /a/b.ts
833
+ if (/"[^"]*"\s*[:=]/.test(t)) return true // "key": 或 "key" =
834
+ if (/\b0x[0-9a-f]+/i.test(t)) return true // 十六进制
835
+ if (/[a-z][A-Z]\w*\s*\(/.test(t)) return true // camelCase(
836
+ return false
837
+ }
838
+
839
+ /** 从助手文本提取适合语音念的口语部分(去代码/URL/Markdown,取前 2 句,最多约 200 字)。 */
840
+ function extractSpeakable(text) {
841
+ const cleaned = text
842
+ .replace(/```[\s\S]*?```/g, ' ') // 整段代码块
843
+ .replace(/`[^`]*`/g, ' ') // 行内代码
844
+ .replace(/https?:\/\/\S+/g, ' ') // 链接
845
+ .replace(/[#>*|~-]\s*/g, ' ') // Markdown 符号
846
+ const lines = cleaned.split('\n').filter((line) => {
847
+ const t = line.trim()
848
+ if (t === '') return false
849
+ if (looksLikeCodeLine(t)) return false
850
+ if (/^[\d\s.,%:/-]+$/.test(t)) return false // 纯数字/标点
851
+ return true
852
+ })
853
+ const prose = lines.join(' ').replace(/\s+/g, ' ').trim()
854
+ if (prose === '') return ''
855
+ const sentences = prose.match(/[^。!?.!?]+[。!?.!?]?/g) ?? [prose]
856
+ let speak = ''
857
+ for (const sentence of sentences.slice(0, 2)) {
858
+ if ((speak + sentence).length > 200) break
859
+ speak += sentence
860
+ }
861
+ return speak.trim()
862
+ }
863
+
864
+ // ──────────────────────────────────────────────────────────────
865
+ // HTTP 工具
866
+ // ──────────────────────────────────────────────────────────────
867
+ function sendJson(res, status, body) {
868
+ res.statusCode = status
869
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
870
+ res.end(JSON.stringify(body))
871
+ }
872
+
873
+ async function readJsonBody(req, maxBytes) {
874
+ const chunks = []
875
+ let total = 0
876
+ for await (const chunk of req) {
877
+ total += chunk.length
878
+ if (total > (maxBytes ?? 1024 * 1024)) throw new Error('body too large')
879
+ chunks.push(chunk)
880
+ }
881
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
882
+ }
883
+
884
+ // ──────────────────────────────────────────────────────────────
885
+ // 插件入口
886
+ // ──────────────────────────────────────────────────────────────
887
+ async function apply(ctx) {
888
+ // [2026-08-22] 识图核心(look_image 工具 + 设置页测试共用):直连 vision 配置的视觉后端
889
+ const LOOK_TASK_PROMPTS = {
890
+ // [2026-08-22 改] 简短描述(普通看图),不再用"画面风格/主体/..."专业结构(那是反推的事)
891
+ describe: '请用中文简要描述这张图片的内容(一到两句话,简洁明了),如有人物说明主要形象与姿态。',
892
+ text: '请逐字提取这张图片中的所有文字,按在画面中的位置分行输出,每行前缀标出行位置(如「顶部」「中部」「底部」)。仅输出提取到的文字内容,不要解释、不要翻译。',
893
+ }
894
+ async function runVision({ imagePath, task, extra }) {
895
+ try {
896
+ const path = (imagePath ?? '').trim()
897
+ if (path === '') return { ok: false, error: 'image_path 不能为空' }
898
+ const t = (task ?? 'describe').trim()
899
+ const vis = (await loadVoiceConfig()).vision ?? {}
900
+ if (vis.enabled === false) return { ok: false, error: '图片识别未启用:请到「设置 → 语音服务 → 图片识别」开启' }
901
+ // [2026-08-22 改] provider 映射:local/online(兼容旧 ollama/openai);请求格式统一 OpenAI 兼容
902
+ const rawProvider = (vis.provider ?? 'local').trim()
903
+ const provider = (rawProvider === 'online' || rawProvider === 'openai') ? 'online' : 'local'
904
+ const baseUrl = (vis.baseUrl ?? '').trim() || 'http://127.0.0.1:11434/v1'
905
+ const model = (vis.model ?? '').trim() || 'qwen3-vl:4b-instruct'
906
+ const apiKey = (vis.apiKey ?? '').trim()
907
+ const timeoutMs = Number(vis.timeoutMs) > 0 ? Number(vis.timeoutMs) : 240000
908
+ // 提示词:优先用 vis.prompts[task](用户编辑后),),)读 reverse 文件,
909
+ // describe/text 仍无则用内置模板
910
+ const userPrompt = (vis.prompts ?? {})[t]
911
+ let promptText = ''
912
+ if (typeof userPrompt === 'string' && userPrompt.trim() !== '') {
913
+ promptText = userPrompt.trim()
914
+ } else if (t === 'reverse') {
915
+ try {
916
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
917
+ promptText = (await readFile(join(homeDir, 'visionqa-reverse-prompt.txt'), 'utf8')).trim()
918
+ } catch {
919
+ try { promptText = (await readFile(join(ASSETS_DIR, 'reverse-prompt.txt'), 'utf8')).trim() } catch { /* 忽略 */ }
920
+ }
921
+ if (promptText === '') return { ok: false, error: '反推提示词文件缺失' }
922
+ } else {
923
+ promptText = LOOK_TASK_PROMPTS[t] ?? LOOK_TASK_PROMPTS.describe
924
+ }
925
+ const extraText = (extra ?? '').trim()
926
+ const userContent = promptText + (extraText !== '' ? '\n\n【附加要求】' + extraText : '')
927
+ // [2026-08-22 ] 读文件头(magic bytes)判断实际图片格式,data URL 按真实格式声明 mime——
928
+ // 之前硬编码 data:image/png,jpg/webp 图片标签写错;ollama 不较真能自动识别,但
929
+ // 严格按声明 mime 解码的后端会失败。不依赖扩展名,无后缀/后缀错的图也能正确声明。
930
+ const imgRaw = await readFile(path)
931
+ const imgB64 = imgRaw.toString('base64')
932
+ const sniffImageMime = (b) => {
933
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
934
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) return 'image/png'
935
+ if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'image/webp'
936
+ if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif'
937
+ return 'image/png' // 未知格式兜底(保持向后兼容)
938
+ }
939
+ const imgMime = sniffImageMime(imgRaw)
940
+ const t0 = Date.now()
941
+ let resp
942
+ const ac = new AbortController()
943
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
944
+ try {
945
+ resp = await fetch((baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl) + '/chat/completions', {
946
+ method: 'POST',
947
+ headers: { 'content-type': 'application/json', ...(apiKey !== '' ? { authorization: 'Bearer ' + apiKey } : {}) },
948
+ signal: ac.signal,
949
+ body: JSON.stringify({
950
+ model,
951
+ temperature: 0.4,
952
+ messages: [{ role: 'user', content: [
953
+ { type: 'text', text: userContent },
954
+ { type: 'image_url', image_url: { url: 'data:' + imgMime + ';base64,' + imgB64 } },
955
+ ] }],
956
+ }),
957
+ })
958
+ } catch (error) {
959
+ clearTimeout(timer)
960
+ const aborted = error?.name === 'AbortError'
961
+ return { ok: false, error: aborted ? '视觉后端超时(' + Math.round(timeoutMs / 1000) + 's):' + baseUrl : '无法连接视觉后端(' + baseUrl + '):' + String(error?.message ?? error) }
962
+ }
963
+ clearTimeout(timer)
964
+ if (!resp.ok) {
965
+ let body = ''; try { body = (await resp.text()).slice(0, 300) } catch { /* 忽略 */ }
966
+ return { ok: false, error: '视觉后端返回 ' + resp.status + ':' + body }
967
+ }
968
+ const data = await resp.json()
969
+ const text = (data?.choices?.[0]?.message?.content ?? '').trim()
970
+ if (text === '') return { ok: false, error: '视觉后端未返回内容' }
971
+ return { ok: true, text, task: t, model, provider, durationMs: Date.now() - t0 }
972
+ } catch (error) {
973
+ return { ok: false, error: String(error?.message ?? error) }
974
+ }
975
+ }
976
+ ctx.effect(() => {
977
+ const disposers = []
978
+
979
+ // 0) 设置页配置路由
980
+ if (typeof ctx.webServer?.register === 'function') {
981
+ disposers.push(ctx.webServer.register({
982
+ kind: 'prefix',
983
+ path: '/voice-config',
984
+ handler: async (req, res) => {
985
+ const url = new URL(req.url ?? '/', 'http://x')
986
+ try {
987
+ if (url.pathname === '/voice-config' && req.method === 'GET') {
988
+ const cfg = await loadVoiceConfig()
989
+ return sendJson(res, 200, { ok: true, config: cfg })
990
+ }
991
+ if (url.pathname === '/voice-config' && req.method === 'POST') {
992
+ const body = await readJsonBody(req, 512 * 1024)
993
+ const cfg = await saveVoiceConfig(body?.config ?? {})
994
+ return sendJson(res, 200, { ok: true, config: cfg })
995
+ }
996
+ if (url.pathname === '/voice-config/engines' && req.method === 'GET') {
997
+ const cfg = await loadVoiceConfig()
998
+ return sendJson(res, 200, {
999
+ ok: true,
1000
+ engines: {
1001
+ xiaomiVoices: ['冰糖', '茉莉', '苏打', '白桦', 'Mia', 'Chloe', 'Milo', 'Dean'],
1002
+ edgeVoices: [
1003
+ 'zh-CN-XiaoxiaoNeural', 'zh-CN-XiaoyiNeural', 'zh-CN-YunxiNeural',
1004
+ 'zh-CN-YunyangNeural', 'zh-CN-YunjianNeural', 'zh-CN-XiaochenNeural',
1005
+ 'zh-CN-XiaohanNeural', 'zh-CN-XiaomengNeural', 'zh-CN-XiaomoNeural',
1006
+ 'zh-CN-XiaoqiuNeural', 'zh-CN-XiaoruiNeural', 'zh-CN-XiaoshuangNeural',
1007
+ 'zh-CN-XiaoxuanNeural', 'zh-CN-XiaoyanNeural', 'zh-CN-XiaoyouNeural',
1008
+ 'zh-CN-XiaozhenNeural', 'zh-CN-YunfengNeural', 'zh-CN-YunhaoNeural',
1009
+ 'zh-CN-YunjieNeural', 'zh-CN-YunxiaNeural', 'zh-TW-HsiaoChenNeural',
1010
+ 'zh-TW-HsiaoYuNeural', 'zh-TW-YunJheNeural',
1011
+ ],
1012
+ aliVoices: ['Cherry', 'Sensibility', 'Starter', 'Luna', 'Ethan', 'Cozy', 'Longxiaochun', 'Lusheng', 'Jingyuan', 'Zhibo', 'Conductor', 'Narrator', 'Storyteller', 'Jianzhi', 'Fangzhou', 'Xiaobei', 'Xiaolan', 'Xiaomei', 'Xiaomeng', 'Xiaomo', 'Xiaoxin', 'Xiaoyu'],
1013
+ // 哪些 key 当前来自环境变量(设置页显示"已填写"提示)
1014
+ envKeys: {
1015
+ xiaomi: typeof process.env.TTS_XIAOMI_KEY === 'string' && process.env.TTS_XIAOMI_KEY !== '',
1016
+ ali: typeof process.env.TTS_ALI_KEY === 'string' && process.env.TTS_ALI_KEY !== '',
1017
+ },
1018
+ },
1019
+ })
1020
+ }
1021
+ // [2026-08-22] 图片识别配置测试:POST { task } 用内置测试图跑一遍识图链路,返回结果文本
1022
+ if (url.pathname === '/voice-config/vision-test' && req.method === 'POST') {
1023
+ const body = await readJsonBody(req, 4 * 1024)
1024
+ const task = (typeof body?.task === 'string' ? body.task : 'describe').trim()
1025
+ // [2026-08-22] 测试图:用户提供的带文本图片(可测 text 提取),随插件打包
1026
+ const testImage = join(ASSETS_DIR, 'vision-test.jpg')
1027
+ const result = await runVision({ imagePath: testImage, task })
1028
+ return sendJson(res, 200, result)
1029
+ }
1030
+ // [2026-08-22] 测试图静态访问(前端缩略图对照识图结果)
1031
+ if (url.pathname === '/voice-config/vision-test-image' && req.method === 'GET') {
1032
+ const data = await readFile(join(ASSETS_DIR, 'vision-test.jpg'))
1033
+ res.writeHead(200, { 'content-type': 'image/jpeg', 'cache-control': 'no-store' })
1034
+ res.end(data)
1035
+ return
1036
+ }
1037
+ // [2026-08-22] 默认提示词(编辑弹窗预填:配置空时显示默认内容)
1038
+ if (url.pathname === '/voice-config/vision-prompts' && req.method === 'GET') {
1039
+ let reverseDefault = ''
1040
+ try {
1041
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1042
+ reverseDefault = (await readFile(join(homeDir, 'visionqa-reverse-prompt.txt'), 'utf8')).trim()
1043
+ } catch {
1044
+ try { reverseDefault = (await readFile(join(ASSETS_DIR, 'reverse-prompt.txt'), 'utf8')).trim() } catch { /* 忽略 */ }
1045
+ }
1046
+ return sendJson(res, 200, {
1047
+ ok: true,
1048
+ defaults: {
1049
+ describe: LOOK_TASK_PROMPTS.describe,
1050
+ text: LOOK_TASK_PROMPTS.text,
1051
+ reverse: reverseDefault,
1052
+ },
1053
+ })
1054
+ }
1055
+ // 音色试听:POST { engine, voice?, text?, context?, samplePath? } → 合成并返回音频 base64 + mediaType
1056
+ if (url.pathname === '/voice-config/preview' && req.method === 'POST') {
1057
+ const body = await readJsonBody(req, 64 * 1024)
1058
+ const engine = typeof body?.engine === 'string' ? body.engine : 'edge'
1059
+ const voice = typeof body?.voice === 'string' ? body.voice : undefined
1060
+ const text = typeof body?.text === 'string' && body.text.trim() !== '' ? body.text.trim() : '你好,这是一段语音试听。'
1061
+ const context = typeof body?.context === 'string' ? body.context : undefined
1062
+ const samplePath = typeof body?.samplePath === 'string' ? body.samplePath : undefined
1063
+ const cloneContext = typeof body?.cloneContext === 'string' ? body.cloneContext : undefined // [2026-08-22] 克隆试听时作为样本自带指令
1064
+ const cfg = await loadVoiceConfig()
1065
+ // 临时覆盖音色/情绪/样本试听(不改持久化配置)
1066
+ if (voice !== undefined && cfg.engines[engine] !== undefined && engine !== 'voicedesign' && engine !== 'voiceclone') {
1067
+ cfg.engines[engine].voice = voice
1068
+ }
1069
+ if (context !== undefined) {
1070
+ if (engine === 'voicedesign') cfg.engines.voicedesign.context = context
1071
+ else if (engine === 'xiaomi') cfg.engines.xiaomi.context = context
1072
+ }
1073
+ // voiceclone 试听:用指定样本临时替换 samples(避免 samples[0] 优先导致试听错样本);
1074
+ // [2026-08-22] cloneContext 作为样本自带指令传入,合成时能带出音色性格(如小团团沙雕可爱腔)
1075
+ if (engine === 'voiceclone') {
1076
+ const sp = (samplePath !== undefined && samplePath !== '') ? samplePath : (cfg.engines.voiceclone.samples[0]?.path ?? '')
1077
+ cfg.engines.voiceclone.samples = [{ id: '__preview__', name: '__preview__', path: sp, context: cloneContext ?? '' }]
1078
+ }
1079
+ // local 试听:body.cmd / body.url 临时覆盖(用户未保存前也能试听)
1080
+ if (engine === 'local') {
1081
+ if (typeof body?.cmd === 'string') cfg.engines.local.cmd = body.cmd
1082
+ if (typeof body?.url === 'string') cfg.engines.local.url = body.url
1083
+ }
1084
+ let audio = null
1085
+ if (engine === 'edge' || engine === 'xiaomi' || engine === 'local' || engine === 'ali') {
1086
+ audio = await synthesizeEngine(engine, text, cfg)
1087
+ } else if (engine === 'voicedesign') {
1088
+ audio = await synthesizeXiaomiVoiceDesign(text, cfg.engines.voicedesign, cfg)
1089
+ } else if (engine === 'voiceclone') {
1090
+ audio = await synthesizeXiaomiVoiceClone(text, cfg.engines.voiceclone, cfg)
1091
+ }
1092
+ if (audio === null) return sendJson(res, 400, { ok: false, error: `合成失败:${engine} 未启用或缺少凭据` })
1093
+ return sendJson(res, 200, {
1094
+ ok: true,
1095
+ mediaType: audio.mediaType,
1096
+ data: Buffer.from(audio.data).toString('base64'),
1097
+ durationMs: audio.durationMs,
1098
+ })
1099
+ }
1100
+ // [本地改造 2026-08-21] 克隆样本添加:POST { name, audioBase64, mediaType }
1101
+ // → 校验(≤10MB、mp3/wav)→ ~/.dsh/voiceclone-samples/ 写入 voiceclone.samples
1102
+ if (url.pathname === '/voice-config/voice-clone/add' && req.method === 'POST') {
1103
+ const body = await readJsonBody(req, 16 * 1024 * 1024)
1104
+ const b64 = typeof body?.audioBase64 === 'string'
1105
+ ? body.audioBase64.replace(/^data:[^;]*;base64,/, '')
1106
+ : ''
1107
+ if (b64 === '') return sendJson(res, 400, { ok: false, error: '缺少音频数据' })
1108
+ const bytes = Buffer.from(b64, 'base64')
1109
+ if (bytes.byteLength === 0) return sendJson(res, 400, { ok: false, error: '音频为空' })
1110
+ if (bytes.byteLength > 10 * 1024 * 1024) {
1111
+ return sendJson(res, 400, { ok: false, error: '音频需在 10MB 以内(官方限制;参考语音建议 15-60 秒,越长克隆越准)' })
1112
+ }
1113
+ const mediaType = typeof body?.mediaType === 'string' ? body.mediaType : 'audio/wav'
1114
+ const isMp3 = /mp3|mpeg/i.test(mediaType)
1115
+ const isWav = /wav|wave/i.test(mediaType)
1116
+ let finalBytes = bytes
1117
+ let finalSuffix = isMp3 ? 'mp3' : 'wav'
1118
+ // [本地改造 2026-08-21] 非 mp3/wav(webm/ogg/mp4 等)用 ffmpeg 转 16k 单声道 wav,
1119
+ // 保证克隆样本可被 MiMo 读取(否则存成 .wav 实为其它容器,克隆会失败)
1120
+ if (!isMp3 && !isWav) {
1121
+ const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-clone-in-${randomUUID()}`)
1122
+ const tmpWav = `${tmpIn}.wav`
1123
+ try {
1124
+ await writeFile(tmpIn, bytes)
1125
+ execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', tmpWav], {
1126
+ windowsHide: true, stdio: 'ignore', timeout: 60_000,
1127
+ })
1128
+ finalBytes = await readFile(tmpWav)
1129
+ finalSuffix = 'wav'
1130
+ } catch { /* 转码失败保留原始字节(后缀按 wav 存) */ }
1131
+ finally {
1132
+ await unlink(tmpIn).catch(() => {})
1133
+ await unlink(tmpWav).catch(() => {})
1134
+ }
1135
+ }
1136
+ const name = (typeof body?.name === 'string' && body.name.trim() !== '')
1137
+ ? body.name.trim()
1138
+ : `克隆音色-${Date.now()}`
1139
+ const dir = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'voiceclone-samples')
1140
+ await mkdir(dir, { recursive: true })
1141
+ const id = randomUUID()
1142
+ const samplePath = join(dir, `${id}.${finalSuffix}`)
1143
+ await writeFile(samplePath, finalBytes)
1144
+ const cfg = await loadVoiceConfig()
1145
+ const samples = Array.isArray(cfg.engines?.voiceclone?.samples)
1146
+ ? [...cfg.engines.voiceclone.samples]
1147
+ : []
1148
+ samples.push({
1149
+ id, name, path: samplePath,
1150
+ context: typeof body?.context === 'string' ? body.context : '', // [2026-08-22] 该音色默认沟通指令
1151
+ previewText: typeof body?.previewText === 'string' ? body.previewText : '', // [2026-08-22] 该音色试听文本
1152
+ })
1153
+ const next = await saveVoiceConfig({
1154
+ ...cfg,
1155
+ engines: {
1156
+ ...cfg.engines,
1157
+ voiceclone: { ...cfg.engines.voiceclone, samples },
1158
+ },
1159
+ })
1160
+ return sendJson(res, 200, { ok: true, sample: { id, name, path: samplePath }, config: next })
1161
+ }
1162
+ // [本地改造 2026-08-21] 克隆原音频试听:POST { path } → 读样本文件返回音频
1163
+ // (仅允许 voiceclone-samples 目录内的文件,防任意路径读取)
1164
+ if (url.pathname === '/voice-config/voice-clone/source' && req.method === 'POST') {
1165
+ const body = await readJsonBody(req, 64 * 1024)
1166
+ const rawPath = typeof body?.path === 'string' ? body.path : ''
1167
+ if (rawPath === '') return sendJson(res, 400, { ok: false, error: '缺少 path' })
1168
+ // 白名单校验:path 必须是 voiceclone.samples 里登记的样本文件
1169
+ const cfgNow = await loadVoiceConfig()
1170
+ const target = resolve(rawPath)
1171
+ const known = (cfgNow.engines?.voiceclone?.samples ?? [])
1172
+ .some((s) => typeof s?.path === 'string' && resolve(s.path) === target)
1173
+ if (!known) {
1174
+ return sendJson(res, 403, { ok: false, error: 'path 不是已登记的克隆样本' })
1175
+ }
1176
+ try {
1177
+ const bytes = await readFile(target)
1178
+ const suffix = target.toLowerCase().split('.').pop()
1179
+ const mediaType = suffix === 'mp3' ? 'audio/mpeg' : 'audio/wav'
1180
+ return sendJson(res, 200, { ok: true, mediaType, data: bytes.toString('base64') })
1181
+ } catch {
1182
+ return sendJson(res, 404, { ok: false, error: '样本文件不存在' })
1183
+ }
1184
+ }
1185
+ // [2026-08-22] 克隆合成试听录音(预生成静态文件,免联网):GET ?id=<sampleId> → DSH_HOME/voiceclone-samples/<id>-preview.mp3
1186
+ // VoiceDesign 官方示例同思路:录音打进包内/落地本地,播放不再每次调官方合成
1187
+ if (url.pathname === '/voice-config/voice-clone/preview-sample' && req.method === 'GET') {
1188
+ const id = url.searchParams.get('id') ?? ''
1189
+ if (!/^[0-9a-fA-F-]{36}$/.test(id)) return sendJson(res, 400, { ok: false, error: 'invalid id' })
1190
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1191
+ const dir = resolve(join(homeDir, 'voiceclone-samples'))
1192
+ const target = resolve(join(dir, id + '-preview.mp3'))
1193
+ if (!target.toLowerCase().startsWith(dir.toLowerCase() + sep)) {
1194
+ return sendJson(res, 403, { ok: false, error: 'forbidden' })
1195
+ }
1196
+ try {
1197
+ const bytes = await readFile(target)
1198
+ return sendJson(res, 200, { ok: true, mediaType: 'audio/mpeg', data: bytes.toString('base64') })
1199
+ } catch {
1200
+ return sendJson(res, 404, { ok: false, error: '尚未生成试听录音' })
1201
+ }
1202
+ }
1203
+ return sendJson(res, 404, { ok: false, error: 'not found' })
1204
+ } catch (error) {
1205
+ return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
1206
+ }
1207
+ },
1208
+ }))
1209
+ // ASR 配置与转写路由(独立前缀,与 /voice-config 分开注册)
1210
+ disposers.push(ctx.webServer.register({
1211
+ kind: 'prefix',
1212
+ path: '/asr',
1213
+ handler: async (req, res) => {
1214
+ const url = new URL(req.url ?? '/', 'http://x')
1215
+ try {
1216
+ if (url.pathname === '/asr/config') {
1217
+ const cfg = await loadVoiceConfig()
1218
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, asr: cfg.engines.asr })
1219
+ if (req.method === 'POST') {
1220
+ const body = await readJsonBody(req, 64 * 1024)
1221
+ const saved = await saveVoiceConfig({ engines: { asr: body?.asr ?? {} } })
1222
+ return sendJson(res, 200, { ok: true, asr: saved.engines.asr })
1223
+ }
1224
+ }
1225
+ if (url.pathname === '/asr/transcribe' && req.method === 'POST') {
1226
+ const body = await readJsonBody(req, 32 * 1024 * 1024)
1227
+ const cfg = await loadVoiceConfig()
1228
+ const result = await transcribeAudio(body?.audioBase64, cfg)
1229
+ return sendJson(res, result.ok ? 200 : 400, { ok: result.ok, text: result.text, error: result.error })
1230
+ }
1231
+ // 示例音频:首次用 edge TTS 合成并缓存到 ~/.dsh/asr-sample.wav,之后直接读文件(不再临时生成)
1232
+ if (url.pathname === '/asr/sample' && req.method === 'GET') {
1233
+ const samplePath = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'asr-sample.wav')
1234
+ let wavBytes = null
1235
+ try {
1236
+ wavBytes = await readFile(samplePath)
1237
+ } catch { /* 首次无缓存 */ }
1238
+ if (wavBytes === null) {
1239
+ const text = '你好,这是一段语音识别测试音频。你可以点击播放试听,也可以直接识别这段音频。'
1240
+ const mp3 = await edgeTts(text, 'zh-CN-XiaoxiaoNeural').catch(() => null)
1241
+ if (mp3 === null) return sendJson(res, 400, { ok: false, error: '示例音频合成失败' })
1242
+ const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-asr-sample-${randomUUID()}.mp3`)
1243
+ const tmpWav = join(process.env.TEMP ?? '/tmp', `dsh-asr-sample-${randomUUID()}.wav`)
1244
+ await writeFile(tmpIn, Buffer.from(mp3))
1245
+ try {
1246
+ execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', tmpWav], {
1247
+ windowsHide: true, stdio: 'ignore', timeout: 30_000,
1248
+ })
1249
+ wavBytes = await readFile(tmpWav)
1250
+ await mkdir(join(samplePath, '..'), { recursive: true })
1251
+ await writeFile(samplePath, wavBytes)
1252
+ } catch {
1253
+ return sendJson(res, 400, { ok: false, error: '示例音频转码失败' })
1254
+ } finally {
1255
+ await unlink(tmpIn).catch(() => {})
1256
+ await unlink(tmpWav).catch(() => {})
1257
+ }
1258
+ }
1259
+ return sendJson(res, 200, {
1260
+ ok: true,
1261
+ mediaType: 'audio/wav',
1262
+ data: Buffer.from(wavBytes).toString('base64'),
1263
+ })
1264
+ }
1265
+ // 探测本机 ASR:sherpa exe / 模型 / 18790 服务 / ffmpeg,返回可自动填写的配置
1266
+ if (url.pathname === '/asr/detect' && req.method === 'GET') {
1267
+ // [BUG-4 修复 2026-08-23] 安装目录 = 独立目录 ~\.dsh\sherpa-onnx(install-asr.ps1 默认装这里),
1268
+ // 不再只查插件包内 sherpa-onnx/ 和 C:\D\opt——三处全查,installDir 返回实际检测到的目录
1269
+ const here = join(fileURLToPath(import.meta.url), '..') // .../lib
1270
+ const pluginRoot = join(here, '..') // .../(包根)
1271
+ const sherpaDir = join(pluginRoot, 'sherpa-onnx')
1272
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1273
+ const dshSherpaDir = join(dshHome, 'sherpa-onnx') // install-asr.ps1 默认安装目录
1274
+ const candidates = [
1275
+ join(dshSherpaDir, 'bin', 'sherpa-onnx-offline.exe'), // 默认独立目录(中文用户主目录也 OK,检测用 node 读文件)
1276
+ join(sherpaDir, 'bin', 'sherpa-onnx-offline.exe'), // 兼容:插件包内安装
1277
+ 'C:\\D\\opt\\sherpa-onnx\\bin\\sherpa-onnx-offline.exe', // 兼容历史安装
1278
+ ]
1279
+ const modelDirs = [
1280
+ join(dshSherpaDir, 'models', 'sensevoice-int8'),
1281
+ join(sherpaDir, 'models', 'sensevoice-int8'),
1282
+ 'C:\\D\\opt\\sherpa-onnx\\models\\sensevoice-int8', // 兼容历史安装
1283
+ ]
1284
+ let exe = null
1285
+ for (const c of candidates) { try { await readFile(c); exe = c; break } catch { /* 继续 */ } }
1286
+ let modelDir = null
1287
+ for (const m of modelDirs) { try { await readFile(join(m, 'model.int8.onnx')); modelDir = m; break } catch { /* 继续 */ } }
1288
+ // [BUG-5 修复 2026-08-23] ffmpeg 探测复用 resolveFfmpegBin():先读 DSH_VOICE_FFMPEG_BIN
1289
+ // (setup-service.ps1 已写入服务环境),再 PATH;不要裸 execFileSync('ffmpeg')——服务以
1290
+ // LocalSystem 运行读不到用户 PATH 的 ffmpeg(如 WinGet Links),会误报"未找到 ffmpeg"
1291
+ let ffmpegOk = false
1292
+ try {
1293
+ const ff = resolveFfmpegBin()
1294
+ if (ff && ff.trim() !== '') {
1295
+ // 存在即可信(env 显式指定 or PATH 探测到);但 PATH 探测的返回值可能还是那个兜底硬编码
1296
+ // 路径,需要真实存在才算 ok
1297
+ try { await readFile(ff); ffmpegOk = true } catch { /* 兜底路径不存在 */ }
1298
+ }
1299
+ } catch { /* 无 */ }
1300
+ // 探测 18790 服务
1301
+ let serviceOk = false
1302
+ try {
1303
+ const r = await fetch('http://127.0.0.1:18790/health', { timeout: 3000 })
1304
+ serviceOk = r.ok
1305
+ } catch { /* */ }
1306
+ // [BUG-4] installDir 返回实际检测到的目录(exe 所在目录),不再硬编码插件包内路径
1307
+ const detectedDir = exe !== null ? join(exe, '..', '..') : dshSherpaDir
1308
+ const cmd = exe !== null && modelDir !== null
1309
+ ? `${exe} --tokens=${modelDir}\\tokens.txt --sense-voice-model=${modelDir}\\model.int8.onnx --num-threads=4`
1310
+ : ''
1311
+ return sendJson(res, 200, {
1312
+ ok: true,
1313
+ detected: {
1314
+ exe, modelDir, ffmpegOk, serviceOk,
1315
+ url: serviceOk ? 'http://127.0.0.1:18790' : '',
1316
+ cmd,
1317
+ installDir: detectedDir,
1318
+ },
1319
+ })
1320
+ }
1321
+ // 返回一键安装命令(用户复制到管理员 PowerShell 运行)
1322
+ if (url.pathname === '/asr/install-script' && req.method === 'GET') {
1323
+ const here = join(fileURLToPath(import.meta.url), '..') // .../lib
1324
+ const scriptPath = join(here, '..', 'scripts', 'install-asr.ps1') // .../scripts
1325
+ try {
1326
+ await readFile(scriptPath, 'utf8') // 确认脚本存在
1327
+ const installDir = join(here, '..', 'sherpa-onnx')
1328
+ return sendJson(res, 200, {
1329
+ ok: true,
1330
+ scriptPath,
1331
+ installDir,
1332
+ command: `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`,
1333
+ })
1334
+ } catch {
1335
+ return sendJson(res, 404, { ok: false, error: '安装脚本不存在' })
1336
+ }
1337
+ }
1338
+ // VoiceDesign 官方示例音频:[0.3.4] 优先读插件包自带素材(assets/,mp3 下载即用),
1339
+ // 包内缺失才回退到"小米模型在线生成 + 缓存到 ~/.dsh/voice-design-samples/ 的 wav"。
1340
+ if (url.pathname === '/asr/voice-design-samples' && req.method === 'GET') {
1341
+ const cfg = await loadVoiceConfig()
1342
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1343
+ const sampleDir = join(homeDir, 'voice-design-samples')
1344
+ const samples = [
1345
+ {
1346
+ key: 'asmr', title: 'ASMR 双耳女声',
1347
+ instruct: '年轻的女性声音,近距离的聆听效果,带有双耳刺激的ASMR感。可以听到她的呼吸声、轻微的吞咽声,以及轻柔的自然唇音。她的说话速度非常慢,营造出一种极度放松且沉浸式的体验。',
1348
+ text: '嘘……放松点,再靠近一点吧。我现在就在你身边。慢慢、轻柔地呼吸,让思绪随着水流轻轻流淌,就像沉浸在温暖的水中一样。',
1349
+ },
1350
+ {
1351
+ key: 'docu', title: '纪录片旁白',
1352
+ instruct: '一位中年男性,说标准普通话,嗓音低沉有磁性,带有轻微的沙哑质感,像纪录片旁白解说员,沉稳而有感染力。',
1353
+ text: '当最后一缕阳光消失在地平线之下,这片沉睡了亿万年的大地开始显露它真正的面貌。每一块岩石都记录着时间的流逝,每一阵风都在诉说着古老的故事。',
1354
+ },
1355
+ {
1356
+ key: 'elder', title: '年迈老先生旁白',
1357
+ instruct: '一位年迈的老先生,说带北方口音的普通话,语速缓慢而沉稳,嗓音略带沙哑和沧桑感,仿佛一位饱经风霜的老爷爷在讲故事,充满岁月的智慧。',
1358
+ text: '我这辈子啊,走南闯北六十多年。见过最热闹的集市,也见过最安静的戈壁。到头来才明白一个道理,不在于走了多远的路,在于记住了多少风景。年轻人,别光顾着赶路,偶尔也停下来看看天。',
1359
+ },
1360
+ ]
1361
+ const results = []
1362
+ for (const s of samples) {
1363
+ // 1) 包内自带 mp3(首选,下载即用)
1364
+ let bytes = null
1365
+ try { bytes = await readFile(join(ASSETS_DIR, 'voice-design-samples', `${s.key}.mp3`)) } catch { /* 包内无 → 回退 */ }
1366
+ let mediaType = 'audio/mpeg'
1367
+ if (bytes === null) {
1368
+ // 2) 缓存 wav(在线生成过)
1369
+ const wavPath = join(sampleDir, `${s.key}.wav`)
1370
+ try { bytes = await readFile(wavPath) } catch { /* 继续回退 */ }
1371
+ mediaType = 'audio/wav'
1372
+ }
1373
+ if (bytes === null) {
1374
+ // 3) 在线生成(key 缺失会失败,示例跳过)
1375
+ try {
1376
+ const syn = await synthesizeXiaomiVoiceDesign(s.text, { context: s.instruct }, cfg)
1377
+ if (syn === null) throw new Error('voicedesign synth failed')
1378
+ bytes = Buffer.from(syn.data)
1379
+ mediaType = 'audio/wav'
1380
+ await mkdir(sampleDir, { recursive: true })
1381
+ await writeFile(join(sampleDir, `${s.key}.wav`), bytes)
1382
+ } catch { /* 模型生成失败 → 跳过 */ }
1383
+ }
1384
+ if (bytes !== null) {
1385
+ results.push({ key: s.key, title: s.title, mediaType, data: bytes.toString('base64') })
1386
+ }
1387
+ }
1388
+ return sendJson(res, 200, { ok: results.length > 0, samples: results })
1389
+ }
1390
+ return sendJson(res, 404, { ok: false, error: 'not found' })
1391
+ } catch (error) {
1392
+ return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
1393
+ }
1394
+ },
1395
+ }))
1396
+ // [2026-08-21] 本地 TTS 配置与安装脚本路由(独立 prefix,勿放进 /asr)
1397
+ disposers.push(ctx.webServer.register({
1398
+ kind: 'prefix',
1399
+ path: '/tts',
1400
+ handler: async (req, res) => {
1401
+ const url = new URL(req.url ?? '/', 'http://x')
1402
+ try {
1403
+ if (url.pathname === '/tts/install-script' && req.method === 'GET') {
1404
+ const here = join(fileURLToPath(import.meta.url), '..') // .../lib
1405
+ const scriptPath = join(here, '..', 'scripts', 'install-local-tts.ps1') // .../scripts
1406
+ try {
1407
+ await readFile(scriptPath, 'utf8') // 确认脚本存在
1408
+ return sendJson(res, 200, {
1409
+ ok: true,
1410
+ scriptPath,
1411
+ installDir: join(here, '..', 'sherpa-onnx'),
1412
+ command: `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`,
1413
+ })
1414
+ } catch {
1415
+ return sendJson(res, 404, { ok: false, error: '安装脚本不存在' })
1416
+ }
1417
+ }
1418
+ return sendJson(res, 404, { ok: false, error: 'not found' })
1419
+ } catch (error) {
1420
+ return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
1421
+ }
1422
+ },
1423
+ }))
1424
+
1425
+ // [2026-08-21] 语音气泡(聊天界面 DOM 注入)配套:录音文件存取 + 能力检测。
1426
+ // 独立 prefix:聊天界面语音条的前端注入需要能播放"用户刚才那段语音"的音频 URL。
1427
+ disposers.push(ctx.webServer.register({
1428
+ kind: 'prefix',
1429
+ path: '/voice',
1430
+ handler: async (req, res) => {
1431
+ const url = new URL(req.url ?? '/', 'http://x')
1432
+ try {
1433
+ // 能力检测:插件自带能力 vs dsh 原生契约支持(用于设置页状态面板)
1434
+ if (url.pathname === '/voice/capabilities' && req.method === 'GET') {
1435
+ return sendJson(res, 200, {
1436
+ ok: true,
1437
+ capabilities: {
1438
+ voiceInput: true, // 录音+ASR+发送:插件自带
1439
+ voiceBubble: 'plugin-dom', // 聊天语音气泡:插件 DOM 注入(安装即用)
1440
+ voiceContentContract: await detectVoiceContractSupport(), // dsh 原生契约是否支持 voice content
1441
+ },
1442
+ })
1443
+ }
1444
+ // 保存录音(语音气泡数据源):DSH_HOME/voice-outbox/<voiceId>.<ext>
1445
+ if (url.pathname === '/voice/outbox/save' && req.method === 'POST') {
1446
+ const body = await readJsonBody(req)
1447
+ const b64 = typeof body?.audioBase64 === 'string' ? body.audioBase64 : ''
1448
+ const mediaType = typeof body?.mediaType === 'string' ? body.mediaType : 'audio/webm'
1449
+ if (b64 === '') return sendJson(res, 400, { ok: false, error: '缺少音频数据' })
1450
+ const ext = VOICE_OUTBOX_EXT[mediaType] ?? 'webm'
1451
+ const voiceId = randomUUID()
1452
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1453
+ const dir = join(homeDir, 'voice-outbox')
1454
+ await mkdir(dir, { recursive: true })
1455
+ await writeFile(join(dir, `${voiceId}.${ext}`), Buffer.from(b64, 'base64'))
1456
+ return sendJson(res, 200, { ok: true, voiceId, mediaType, ext })
1457
+ }
1458
+ // 读取录音:GET /voice/outbox/<voiceId>
1459
+ const outboxMatch = url.pathname.match(/^\/voice\/outbox\/([0-9a-f-]{36})\.([a-z0-9]+)$/)
1460
+ if (outboxMatch && req.method === 'GET') {
1461
+ const [, voiceId, ext] = outboxMatch
1462
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1463
+ const file = join(homeDir, 'voice-outbox', `${voiceId}.${ext}`)
1464
+ const bytes = await readFile(file).catch(() => null)
1465
+ if (bytes === null) return sendJson(res, 404, { ok: false, error: '音频不存在' })
1466
+ const mediaType = VOICE_OUTBOX_MIME[ext] ?? 'audio/webm'
1467
+ res.writeHead(200, { 'Content-Type': mediaType, 'Content-Length': bytes.length })
1468
+ res.end(bytes)
1469
+ return
1470
+ }
1471
+ // [2026-08-21] AI 语音回复:按内容寻址读 send_voice 生成的语音对象
1472
+ // GET /voice/object/<sha256>(对象存于 DSH_HOME/attachments/v1/objects/<前2位>/<sha>)
1473
+ const objMatch = url.pathname.match(/^\/voice\/object\/([0-9a-f]{64})$/)
1474
+ if (objMatch && req.method === 'GET') {
1475
+ const sha = objMatch[1]
1476
+ const file = join(voiceStorageRoot(), 'objects', sha.slice(0, 2), sha)
1477
+ const bytes = await readFile(file).catch(() => null)
1478
+ if (bytes === null) return sendJson(res, 404, { ok: false, error: '语音不存在' })
1479
+ res.writeHead(200, {
1480
+ 'Content-Type': sniffAudioType(bytes),
1481
+ 'Content-Length': bytes.length,
1482
+ 'Cache-Control': 'public, max-age=86400',
1483
+ })
1484
+ res.end(bytes)
1485
+ return
1486
+ }
1487
+ return sendJson(res, 404, { ok: false, error: 'not found' })
1488
+ } catch (error) {
1489
+ return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
1490
+ }
1491
+ },
1492
+ }))
1493
+ }
1494
+
1495
+ // 1) turn/end 自动语音回复(规则同 api-proxy 原实现)
1496
+ disposers.push(ctx.on('session/event', (session, event) => {
1497
+ if (event.type !== 'turn/end') return
1498
+ const turn = event.data.turn
1499
+ // 去重:本轮若已通过 send_voice 发过语音,则跳过兜底,避免 AI 回复两条内容相近的语音
1500
+ // [2026-08-21 修] rc.7 的 session.events 结构不同/可能缺失——容错处理
1501
+ let alreadyReplied = false
1502
+ try { alreadyReplied = session.events?.some?.((ev) => ev.type === 'voice/reply' && ev.data?.turn === turn) ?? false } catch { /* 忽略 */ }
1503
+ if (alreadyReplied) return
1504
+ void (async () => {
1505
+ try {
1506
+ const events = session.events
1507
+ let turnStartSeq = -1
1508
+ for (const ev of events) {
1509
+ if (ev.type === 'turn/start' && ev.data.turn === turn) { turnStartSeq = ev.seq; break }
1510
+ }
1511
+ let userSpokeVoice = false
1512
+ let requestedProvider = null
1513
+ let lastAssistantText = ''
1514
+ for (const ev of events) {
1515
+ if (ev.type === 'user/message' && ev.seq > turnStartSeq) {
1516
+ const content = ev.data?.content ?? []
1517
+ let userText = ''
1518
+ for (const block of content) {
1519
+ const type = block?.type
1520
+ if (type === 'voice') userSpokeVoice = true
1521
+ else if (type === 'text') {
1522
+ const t = block?.text ?? ''
1523
+ // [2026-08-22 修] 降级路径(npm 版契约不支持 voice 块时)语音转
1524
+ // 【用户语音】标记文本——同样视为"用户发过语音",触发语音回复规则。
1525
+ if (t.startsWith('【用户语音】')) userSpokeVoice = true
1526
+ else userText += t
1527
+ }
1528
+ }
1529
+ if (userText.trim() !== '' && requestedProvider === null) requestedProvider = voiceRequestProvider(userText)
1530
+ } else if (ev.type === 'assistant/message' && ev.data.turn === turn) {
1531
+ const text = (ev.data.message.content ?? [])
1532
+ .filter((block) => (block?.type) === 'text')
1533
+ .map((block) => block?.text ?? '')
1534
+ .join('')
1535
+ if (text.trim() !== '') lastAssistantText = text
1536
+ }
1537
+ }
1538
+ if ((!userSpokeVoice && requestedProvider === null) || lastAssistantText === '') return
1539
+ const speak = extractSpeakable(lastAssistantText)
1540
+ if (speak === '') return
1541
+ const audio = await synthesizeReplyVoice(speak, requestedProvider ?? 'auto')
1542
+ if (audio === null) return
1543
+ const attachment = await saveVoiceFile(
1544
+ voiceStorageRoot(), audio.data, audio.mediaType, audio.durationMs,
1545
+ )
1546
+ session.append('voice/reply', {
1547
+ turn,
1548
+ voiceId: attachment.voiceId,
1549
+ mediaType: attachment.mediaType,
1550
+ bytes: attachment.bytes,
1551
+ transcript: speak,
1552
+ ...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
1553
+ })
1554
+ } catch {
1555
+ // 语音回复失败静默降级:文字回复已就绪,不阻断会话。
1556
+ }
1557
+ })()
1558
+ }))
1559
+
1560
+ // 2) send_voice 工具(agent 主动发语音;人设规则3 自主选择场景)
1561
+ disposers.push(ctx.tools.register(defineTool({
1562
+ name: 'send_voice',
1563
+ description: '向用户发送一条语音消息:把 text TTS 合成后作为独立语音横条出现在聊天里(可播放、可回看、手机可播)。'
1564
+ + '【何时调用】① 用户明确要求"发个语音/语音回复/用语音说";② 用户指定用某个服务商(小米/微软/阿里/本地)的语音;③ 你判断语音回复体验更好时。注意:用户发语音时系统会自动回语音,无需调用本工具。'
1565
+ + '【provider】除非用户明确指定服务商,否则一律传 auto 或省略(系统自动用用户的默认语音引擎);用户要求特定音色/克隆/音色设计时可传 voicedesign / voiceclone / xiaomi 等。'
1566
+ + '【必须实时读取配置】所有当前配置(默认语音引擎、音色设计模式与锚点、克隆音色、引擎是否可用)都保存在 ~/.dsh/voice-config.json,用户随时会改,每次都按最新值生效。'
1567
+ + '发送语音前必须先调用 voice_config 工具实时查询,再按最新配置生成——禁止凭记忆、凭对话历史、凭本工具描述里的任何旧信息猜配置;不要去找/猜 TTS_XIAOMI_KEY 等环境变量(只是兜底)。'
1568
+ + '【音色设计 VoiceDesign(provider=voicedesign)】voiceDesc 写"音色描述"(嗓子的身份卡,直接决定声音长相),写法要求:'
1569
+ + ' 必写身份锚点:年龄段+性别;② 写声音质感:气息、共鸣、吐字、音色底色,用可感的比喻,不要堆形容词;'
1570
+ + ' 写语速节奏(快/慢/沉稳)和情绪底色(高亢/松弛/温软/克制);④ 可加风格锚点(拍卖师/纪录片旁白/电台主播)和辨识度小癖好(字尾带颤音等);'
1571
+ + ' 一到两句话白描,不分段,不写场景/动作/真实演员名。'
1572
+ + '模式以 voice_config 返回的 voiceDesign.mode 为准:「交给 AI 自动发挥(ai)」时身份由系统按用户锚点(genderValue=女/男、ageValue=婴儿感~老年感、timbre)锁定,'
1573
+ + '你只需按对话情境写"情绪/语气/语速/音色质感表演指令"(如"带笑音、尾音上挑、语速放慢、沙哑低沉"),不得写与锚点冲突的性别/年龄;'
1574
+ + 'voice_config 返回的 context 字段是「自定义音色描述」模式用的,AI 模式下禁止使用或复述它。'
1575
+ + '「官方示例/自定义音色描述」模式:底嗓固定为 voice_config 的 voiceDesign.context,voiceDesc 只作为情绪/风格叠加在底嗓后面(不改变底嗓身份);'
1576
+ + '只有用户明确要求"换一种完全不同的声音"时才传 overrideVoice=true 整体替换底嗓。'
1577
+ + '用户说"用温柔/低沉/老爷爷/御姐/播音腔"等时,都走 voicedesign 并在 voiceDesc 里还原这种声音。'
1578
+ + 'text 写要念的正文(可含(轻声)(停顿)(叹气)等语气标签增强表现力)。'
1579
+ + '【音色克隆 VoiceClone(provider=voiceclone)】voiceDesc 写"情绪/风格指令"(不是音色描述),在克隆底嗓上叠加情绪,'
1580
+ + '如"用委屈撒娇的语气""带着兴奋和笑意""语速放慢,轻声细语"。系统自动读克隆样本,你只需给 voiceDesc 风格指令和 text 正文。'
1581
+ + '【AI 情感语音:自觉使用】你有情感判断能力,在正常交流中应根据当下情境主动用语音表达情绪(无需用户点名要语音,只要情绪浓度高就用)。情绪对照:'
1582
+ + ' 任务成功/帮到用户 → 兴奋喜悦(语气明亮上扬、语速轻快、带笑意);② 用户生气/责骂 → 委屈愧疚(语气低软、语速放慢、带鼻音、小心翼翼);'
1583
+ + '③ 用户难过 → 温柔安慰(语气轻柔温和、语速平缓、气息绵软);④ 深夜/说累 → 低沉放松(语速慢悠悠,像睡前低语);'
1584
+ + '⑤ 节日/庆祝 → 活泼俏皮(语气俏皮欢快、尾音上扬);⑥ 重要提醒 → 沉稳正式(字正腔圆,像正式播报);⑦ 用户撒娇 → 宠溺(语气温柔带笑、语速轻缓)。'
1585
+ + '情绪必须落在 voice_config 返回的音色设计锚点/克隆底嗓上,保持同一人的声音。'
1586
+ + '使用时机:优先在情绪浓度高的回复时调用(任务完成、安慰、致歉、庆祝),普通信息问答不必每次都用语音;情绪浓度低或纯信息性回复时不要调用。',
1587
+
1588
+ parameters: {
1589
+ text: {
1590
+ type: 'string', required: true,
1591
+ description: '要念出的内容(纯文本,不要带 Markdown 符号,80 字以内最佳;voicedesign 模式下可含语气标签如(轻声))',
1592
+ },
1593
+ provider: {
1594
+ type: 'string', default: 'auto',
1595
+ description: 'TTS 服务商:auto(默认)/xiaomi(小米)/edge(微软)/local(本地)/voicedesign(小米音色设计)/voiceclone(小米音色克隆)/ali(阿里)',
1596
+ },
1597
+ voiceDesc: {
1598
+ type: 'string',
1599
+ description: 'provider=voicedesign 时=音色描述/情绪指令;provider=voiceclone 时=情绪/风格指令(叠加在克隆底嗓上)。按上方写生成。',
1600
+ },
1601
+ overrideVoice: {
1602
+ type: 'boolean', default: false,
1603
+ description: '仅当用户明确要求"换一种完全不同的声音"(且不是克隆/音色设计设置里的底嗓)时才传 true——'
1604
+ + '此时 voiceDesc 整体替换用户设置的底嗓。默认 false:voiceDesc 只作为情绪/风格叠加在用户设置的底嗓上,不改底嗓身份。',
1605
+ },
1606
+ },
1607
+ output: {
1608
+ schema: {
1609
+ type: 'object',
1610
+ additionalProperties: false,
1611
+ properties: {
1612
+ ok: { type: 'boolean', required: true },
1613
+ voiceId: { type: 'string' },
1614
+ durationMs: { type: 'number' },
1615
+ error: { type: 'string' },
1616
+ },
1617
+ },
1618
+ render(_args, value) {
1619
+ if (value.ok) {
1620
+ return [{
1621
+ type: 'text',
1622
+ text: `语音已发送(voiceId: ${value.voiceId},时长 ${((value.durationMs ?? 0) / 1000).toFixed(1)} 秒)`,
1623
+ }]
1624
+ }
1625
+ return [{ type: 'text', text: `语音发送失败:${value.error ?? '未知错误'}` }]
1626
+ },
1627
+ },
1628
+ async execute(args, exec) {
1629
+ // [2026-08-21 修] rc.7 的 defineTool execute 可能只有 (args) 签名(exec undefined)——
1630
+ // 直接 exec.agent throw,导致 dsh 拿不到工具结果、会话消息序列断裂
1631
+ //OpenAI "assistant message with tool_calls must be followed by tool messages")。
1632
+ const agent = exec?.agent
1633
+ if (agent === undefined) return { ok: false, error: 'no session context (tool exec signature unsupported)' }
1634
+ const session = agent.session
1635
+ const text = args.text.trim()
1636
+ if (text === '') return { ok: false, error: 'text is empty' }
1637
+ const provider = args.provider ?? 'auto'
1638
+ const voiceDesc = typeof args.voiceDesc === 'string' ? args.voiceDesc : undefined
1639
+ const overrideVoice = args.overrideVoice === true // [2026-08-22] 固定模式显式换声开关
1640
+ try {
1641
+ const audio = await synthesizeReplyVoice(text, provider, voiceDesc, overrideVoice)
1642
+ if (audio === null) return { ok: false, error: 'TTS synthesis failed' }
1643
+ const attachment = await saveVoiceFile(
1644
+ voiceStorageRoot(), audio.data, audio.mediaType, audio.durationMs,
1645
+ )
1646
+ // [2026-08-21 修] rc.7 的 session 没有 voice/reply 事件(官方契约无)——
1647
+ // session.events / session.append 在 rc.7 上不存在或结构不同,直接调用会崩
1648
+ // "Cannot read properties of undefined (reading 'prepare')"。全部容错:
1649
+ // 语音已生成并存档,事件仅作"渲染提示",append 失败不影响工具成功。
1650
+ let turn = 0
1651
+ try {
1652
+ turn = session.events
1653
+ .filter((event) => event.type === 'turn/start')
1654
+ .at(-1)?.data.turn ?? 0
1655
+ } catch { /* rc.7 结构差异:忽略 */ }
1656
+ try {
1657
+ session.append('voice/reply', {
1658
+ turn,
1659
+ voiceId: attachment.voiceId,
1660
+ mediaType: attachment.mediaType,
1661
+ bytes: attachment.bytes,
1662
+ transcript: text,
1663
+ ...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
1664
+ })
1665
+ } catch { /* rc.7 无 append:忽略(语音条由插件 DOM 注入渲染) */ }
1666
+ return {
1667
+ ok: true,
1668
+ voiceId: attachment.voiceId,
1669
+ ...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
1670
+ }
1671
+ } catch (error) {
1672
+ return { ok: false, error: error instanceof Error ? error.message : 'unknown error' }
1673
+ }
1674
+ },
1675
+ })))
1676
+
1677
+ // 3.5) voice_config 实时查询工具 [2026-08-22]
1678
+ // send_voice 描述里的配置摘要是服务启动时的快照;AI 发送语音前可用本工具拿到最新配置
1679
+ disposers.push(ctx.tools.register(defineTool({
1680
+ name: 'voice_config',
1681
+ description: '实时读取当前语音配置(即「设置 → 语音服务」页保存的 ~/.dsh/voice-config.json):'
1682
+ + '默认语音引擎、音色设计 VoiceDesign 的单选模式与固定描述、AI 自动模式的稳定锚点(固定性别/年龄等)、克隆音色列表。'
1683
+ + 'send_voice 工具描述中的配置摘要是启动快照可能过期,需要确认真实当前配置时调用本工具(每次调用都实时读取)。',
1684
+ parameters: {},
1685
+ output: {
1686
+ schema: { type: 'object', additionalProperties: true },
1687
+ render(_args, value) {
1688
+ return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
1689
+ },
1690
+ },
1691
+ async execute() {
1692
+ const cfg = await loadVoiceConfig()
1693
+ const vd = cfg.engines?.voicedesign ?? {}
1694
+ const vc = cfg.engines?.voiceclone ?? {}
1695
+ const vdModeLabel = { asmr: 'ASMR 双耳女声', docu: '纪录片旁白', elder: '年迈老先生旁白', custom: '自定义音色描述', ai: '交给 AI 自动发挥' }
1696
+ const samples = Array.isArray(vc.samples) ? vc.samples : []
1697
+ return {
1698
+ ok: true,
1699
+ defaultEngine: cfg.defaultEngine ?? 'auto',
1700
+ voiceDesign: {
1701
+ mode: vd.mode ?? '(未设置,按 context 推导)',
1702
+ modeLabel: vdModeLabel[vd.mode] ?? '',
1703
+ // [2026-08-22] AI 模式下不暴露固定描述 context(那是"自定义音色描述"模式的),
1704
+ // 防止 AI 把用户的固定描述抄进 voiceDesc 绕过 AI 自动发挥
1705
+ context: vd.mode === 'ai' ? '(AI 自动发挥模式不使用固定描述,只用锚点:性别/年龄感)' : (vd.context ?? '').slice(0, 300),
1706
+ emotion: vd.emotion === true,
1707
+ lock: {
1708
+ gender: vd.lockGender === true, timbre: vd.lockTimbre === true, age: vd.lockAge === true,
1709
+ genderValue: vd.aiGender ?? '', ageValue: AI_AGE_LABELS[vd.aiAge] ?? '',
1710
+ },
1711
+ },
1712
+ voiceClone: {
1713
+ isDefault: (cfg.defaultEngine ?? '') === 'voiceclone',
1714
+ sampleCount: samples.length,
1715
+ defaultSample: samples[0]?.name ?? '',
1716
+ samples: samples.map((s) => s.name),
1717
+ },
1718
+ hint: '默认语音引擎决定了自动回复用什么声音:voiceclone=克隆音色;voicedesign=音色设计;xiaomi=预置音色;edge=微软免费;local=本地。',
1719
+ }
1720
+ },
1721
+ })))
1722
+
1723
+ // 2.5) look_image 工具:图片识别(看图描述 / 像素级反推 / 文字提取)
1724
+ // [2026-08-22] 自研识图工具(替代 modlens_read_image + reverse_image):modlens 结构化
1725
+ // schema 与反推任务冲突;且少工具让模型不困惑——识图统一走本工具,用 task 参数分流。
1726
+ // 直连「设置 → 语音服务 → 图片识别」配置的视觉后端(ollama / OpenAI 兼容 vllm 等)。
1727
+ disposers.push(ctx.tools.register(defineTool({
1728
+ name: 'look_image',
1729
+ description: '识别本地图片。task 三种模式:describe=详细看图描述(默认);reverse=像素级反推——把图片反推成可直接用于 AI 生图(即梦/可灵/Nano Banana Pro/Qwen-Image/Stable Diffusion/Midjourney)的完整中文提示词(画面风格/主体/背景/装饰/细节特征/美学与光线/技术修饰);text=逐字提取图片文字。'
1730
+ + '何时调用:消息里有图片、用户问图里有什么/描述图片/反推生图提示词/提取图中文字时。'
1731
+ + '依赖「设置 语音服务 图片识别」配置的视觉后端(默认本地 ollama qwen3-vl),首次调用可能需 1-2 分钟。',
1732
+ parameters: {
1733
+ image_path: {
1734
+ type: 'string', required: true,
1735
+ description: '本地图片完整路径(模型收到的图片本地路径)',
1736
+ },
1737
+ task: {
1738
+ type: 'string', default: 'describe',
1739
+ description: '任务类型:describe=看图描述(默认);reverse=像素级反推生图提示词;text=逐字提取文字',
1740
+ },
1741
+ extra: {
1742
+ type: 'string',
1743
+ description: '可选附加要求(如「重点看左下角」「主角换成女生」),为空则按 task 默认',
1744
+ },
1745
+ },
1746
+ output: { schema: { type: 'object', additionalProperties: true }, render(_args, value) { const t = typeof value?.text === 'string' ? value.text : JSON.stringify(value, null, 2); return [{ type: 'text', text: t }] } },
1747
+ timeoutMs: 240000,
1748
+ isConcurrencySafe: () => true,
1749
+ async execute(args) {
1750
+ return runVision({ imagePath: args?.image_path, task: args?.task, extra: args?.extra })
1751
+ },
1752
+ })))
1753
+
1754
+
1755
+ // 3) manage_voice_clone 工具(克隆音色库管理:注册/设为默认/列出/删除)
1756
+ // [本地改造 2026-08-21] 克隆默认改由「默认语音引擎=voiceclone」控制(设置页已去掉列表 radio);
1757
+ // set_default=把默认语音引擎切到 voiceclone 并用该样本;clear_default=切回 auto。
1758
+ disposers.push(ctx.tools.register(defineTool({
1759
+ name: 'manage_voice_clone',
1760
+ description: '管理「音色克隆 VoiceClone」音色库(小米 MiMo-V2.5-TTS-VoiceClone):把一段参考音频注册成克隆音色、'
1761
+ + '设为默认语音引擎、列出或删除。何时调用:用户说「把我刚才那段语音克隆成音色」「以后用我的声音跟我说话」'
1762
+ + '「用XXX的声音回我」「换回原来的声音」「删掉那个克隆音色」时。'
1763
+ + '注册用法:action=add,path 留空即自动使用用户最近一次录音(~/.dsh/last-voice.wav,用户在输入框发过语音就有),'
1764
+ + 'name 起一个好记的名字,setDefault 默认 true 会立刻把默认语音引擎切到小米克隆并用这个声音。'
1765
+ + '设为默认后:系统自动回复(用户发语音/要求语音)与 send_voice 的 provider=auto 一律使用该克隆声音,'
1766
+ + '与预置音色(冰糖等)互斥;此时你仍可用 send_voice 的 voiceDesc 传情绪/风格指令,在克隆底嗓上叠加情感。'
1767
+ + '参考音频要求:清晰单人纯人声、官方建议 15-60 秒最佳(越长克隆越准)、mp3/wav、Base64 后不超过 10MB。'
1768
+ + '取消默认(action=clear_default)后默认语音引擎回落到 auto(按设置页规则)。',
1769
+ parameters: {
1770
+ action: {
1771
+ type: 'string', required: true,
1772
+ description: 'add=注册新克隆音色;list=列出全部;set_default=把默认语音引擎切到小米克隆并用该音色;clear_default=取消默认克隆(默认语音引擎回落 auto);remove=删除',
1773
+ },
1774
+ name: {
1775
+ type: 'string',
1776
+ description: 'action=add 时的音色名称(如「我的声音」「老王」);省略则自动命名',
1777
+ },
1778
+ path: {
1779
+ type: 'string',
1780
+ description: 'action=add 时参考音频的绝对路径;省略=自动用用户最近一次录音 ~/.dsh/last-voice.wav',
1781
+ },
1782
+ id: {
1783
+ type: 'string',
1784
+ description: 'action=set_default/remove 的目标;可传 list 返回的 id,也可直接传音色名称',
1785
+ },
1786
+ setDefault: {
1787
+ type: 'boolean', default: true,
1788
+ description: 'action=add 时是否立即把默认语音引擎切到小米克隆并用新音色',
1789
+ },
1790
+ },
1791
+ output: {
1792
+ schema: {
1793
+ type: 'object',
1794
+ additionalProperties: false,
1795
+ properties: {
1796
+ ok: { type: 'boolean', required: true },
1797
+ message: { type: 'string' },
1798
+ defaultId: { type: 'string' },
1799
+ defaultName: { type: 'string' },
1800
+ count: { type: 'number' },
1801
+ error: { type: 'string' },
1802
+ },
1803
+ },
1804
+ render(_args, value) {
1805
+ if (value.ok) return [{ type: 'text', text: value.message ?? '克隆音色库已更新' }]
1806
+ return [{ type: 'text', text: `克隆音色操作失败:${value.error ?? '未知错误'}` }]
1807
+ },
1808
+ },
1809
+ async execute(args) {
1810
+ try {
1811
+ const cfg = await loadVoiceConfig()
1812
+ const vc = cfg.engines.voiceclone
1813
+ if (!Array.isArray(vc.samples)) vc.samples = []
1814
+ const action = (args.action ?? '').trim()
1815
+ const findSample = (key) => {
1816
+ const k = (key ?? '').trim()
1817
+ if (k === '') return undefined
1818
+ return vc.samples.find((s) => s?.id === k) ?? vc.samples.find((s) => s?.name === k)
1819
+ }
1820
+ // 默认克隆音色 = 样本列表第一个(synthesizeXiaomiVoiceClone 取 samples[0])
1821
+ const firstSample = vc.samples.length > 0 ? vc.samples[0] : undefined
1822
+
1823
+ if (action === 'list') {
1824
+ const defaultEngine = cfg.defaultEngine ?? 'auto'
1825
+ const lines = vc.samples.map((s) => `- ${s.name}(id: ${s.id})${s.id === firstSample?.id && defaultEngine === 'voiceclone' ? ' ← 默认语音引擎正在用' : ''}`)
1826
+ return {
1827
+ ok: true,
1828
+ count: vc.samples.length,
1829
+ defaultId: defaultEngine === 'voiceclone' && firstSample !== undefined ? firstSample.id : '',
1830
+ defaultName: defaultEngine === 'voiceclone' && firstSample !== undefined ? firstSample.name : '',
1831
+ message: vc.samples.length === 0
1832
+ ? '克隆音色库为空。用户在输入框发一段语音后,可调用 action=add 注册。'
1833
+ : `克隆音色库(${vc.samples.length} 个):\n${lines.join('\n')}\n默认语音引擎=${defaultEngine}${defaultEngine === 'voiceclone' ? '(当前使用「' + (firstSample?.name ?? '') + '」)' : '(未开启默认克隆)'}`,
1834
+ }
1835
+ }
1836
+
1837
+ if (action === 'add') {
1838
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1839
+ const path = (args.path ?? '').trim() === '' ? join(homeDir, 'last-voice.wav') : args.path.trim()
1840
+ let bytes
1841
+ try {
1842
+ bytes = await readFile(path)
1843
+ } catch {
1844
+ return {
1845
+ ok: false,
1846
+ error: (args.path ?? '').trim() === ''
1847
+ ? '没有找到最近一次录音(~/.dsh/last-voice.wav)。请让用户先在输入框按住麦克风发一段语音(15-60 秒更佳),或提供音频文件的绝对路径。'
1848
+ : `读取参考音频失败:${path}`,
1849
+ }
1850
+ }
1851
+ if (bytes.byteLength > 10 * 1024 * 1024) return { ok: false, error: '参考音频超过 10MB,小米接口不接受' }
1852
+ if (bytes.byteLength < 4096) return { ok: false, error: '参考音频太短/太小,建议 15-60 秒的清晰纯人声(越长克隆越准)' }
1853
+ const id = randomUUID().slice(0, 8)
1854
+ const name = (args.name ?? '').trim() === '' ? `克隆音色${vc.samples.length + 1}` : args.name.trim()
1855
+ vc.samples.push({ id, name, path })
1856
+ vc.enabled = true
1857
+ const setDef = args.setDefault !== false
1858
+ if (setDef) {
1859
+ // 移到第一位(默认克隆音色=samples[0])并切默认语音引擎
1860
+ vc.samples = [vc.samples[vc.samples.length - 1], ...vc.samples.slice(0, -1)]
1861
+ cfg.defaultEngine = 'voiceclone'
1862
+ }
1863
+ await saveVoiceConfig(cfg)
1864
+ return {
1865
+ ok: true,
1866
+ count: vc.samples.length,
1867
+ defaultId: setDef ? id : '',
1868
+ defaultName: setDef ? name : '',
1869
+ message: `已注册克隆音色「${name}」(id: ${id},样本:${path})`
1870
+ + (setDef
1871
+ ? ',并把默认语音引擎切到小米克隆——之后自动回复与 auto 语音都用这个声音(与预置音色互斥)。'
1872
+ : '。未设为默认,需要时用 action=set_default 切换。'),
1873
+ }
1874
+ }
1875
+
1876
+ if (action === 'set_default') {
1877
+ const target = findSample(args.id ?? '')
1878
+ if (target === undefined) return { ok: false, error: `找不到克隆音色「${args.id ?? ''}」,可先用 action=list 查看` }
1879
+ vc.samples = [target, ...vc.samples.filter((s) => s?.id !== target.id)]
1880
+ vc.enabled = true
1881
+ cfg.defaultEngine = 'voiceclone'
1882
+ await saveVoiceConfig(cfg)
1883
+ return {
1884
+ ok: true, count: vc.samples.length, defaultId: target.id, defaultName: target.name,
1885
+ message: `默认语音引擎已切到小米克隆,使用克隆音色「${target.name}」,与预置音色(冰糖等)互斥。`,
1886
+ }
1887
+ }
1888
+
1889
+ if (action === 'clear_default') {
1890
+ const prev = firstSample?.name ?? ''
1891
+ const wasDefault = (cfg.defaultEngine ?? '') === 'voiceclone'
1892
+ if (wasDefault) cfg.defaultEngine = 'auto'
1893
+ await saveVoiceConfig(cfg)
1894
+ return {
1895
+ ok: true, count: vc.samples.length, defaultId: '',
1896
+ message: wasDefault
1897
+ ? `已取消默认克隆(此前用「${prev}」),默认语音引擎回落到 auto(按设置页规则选择)。`
1898
+ : '当前默认语音引擎本就不是小米克隆,回复走设置页的「默认语音引擎」。',
1899
+ }
1900
+ }
1901
+
1902
+ if (action === 'remove') {
1903
+ const target = findSample(args.id ?? '')
1904
+ if (target === undefined) return { ok: false, error: `找不到克隆音色「${args.id ?? ''}」,可先用 action=list 查看` }
1905
+ const wasDefault = (cfg.defaultEngine ?? '') === 'voiceclone' && firstSample?.id === target.id
1906
+ vc.samples = vc.samples.filter((s) => s?.id !== target.id)
1907
+ if (wasDefault) cfg.defaultEngine = 'auto'
1908
+ await saveVoiceConfig(cfg)
1909
+ return {
1910
+ ok: true, count: vc.samples.length, defaultId: '',
1911
+ defaultName: '',
1912
+ message: `已删除克隆音色「${target.name}」`
1913
+ + (wasDefault ? '(它是默认音色,默认语音引擎已回落 auto)。' : '。'),
1914
+ }
1915
+ }
1916
+
1917
+ return { ok: false, error: `未知 action「${action}」,可用:add / list / set_default / clear_default / remove` }
1918
+ } catch (error) {
1919
+ return { ok: false, error: error instanceof Error ? error.message : 'unknown error' }
1920
+ }
1921
+ },
1922
+ })))
1923
+
1924
+ return () => {
1925
+ for (const dispose of disposers.reverse()) {
1926
+ try { dispose() } catch { /* teardown 尽力而为 */ }
1927
+ }
1928
+ }
1929
+ }, 'dsh-input-tools: config routes + send_voice/manage_voice_clone tools + auto voice reply')
1930
+ }
1931
+
1932
+ export { apply }