@oadank/dsh-input-tools 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1072 -0
- package/lib/edge-tts.js +117 -0
- package/lib/index.js +1394 -0
- package/package.json +33 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1394 @@
|
|
|
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 } 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 } 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
|
+
// 配置中心:~/.dsh/voice-config.json
|
|
39
|
+
// ──────────────────────────────────────────────────────────────
|
|
40
|
+
const CONFIG_PATH = resolve(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'voice-config.json'))
|
|
41
|
+
|
|
42
|
+
function defaultVoiceConfig() {
|
|
43
|
+
return {
|
|
44
|
+
defaultEngine: 'auto',
|
|
45
|
+
engines: {
|
|
46
|
+
edge: { enabled: true, voice: 'zh-CN-XiaoxiaoNeural' },
|
|
47
|
+
xiaomi: {
|
|
48
|
+
enabled: true,
|
|
49
|
+
apiKey: '',
|
|
50
|
+
baseUrl: 'https://api.xiaomimimo.com/v1',
|
|
51
|
+
voice: '冰糖',
|
|
52
|
+
singing: false,
|
|
53
|
+
context: '',
|
|
54
|
+
},
|
|
55
|
+
voicedesign: { enabled: false, context: '', emotion: true }, // emotion=AI 情感语音开关(默认开)
|
|
56
|
+
voiceclone: { enabled: false, samples: [], samplePath: '', context: '', defaultId: '' }, // [本地改造 2026-08-21] defaultId 已废弃,默认克隆由 defaultEngine=voiceclone 控制
|
|
57
|
+
local: { enabled: true, url: '', cmd: '' },
|
|
58
|
+
ali: {
|
|
59
|
+
enabled: false,
|
|
60
|
+
apiKey: '',
|
|
61
|
+
baseUrl: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation',
|
|
62
|
+
voice: 'Cherry',
|
|
63
|
+
},
|
|
64
|
+
asr: {
|
|
65
|
+
enabled: true,
|
|
66
|
+
mode: 'service', // service=本地常驻服务 / cmd=本地命令 / api=在线 API
|
|
67
|
+
url: 'http://127.0.0.1:18790', // sherpa-onnx 常驻服务(nssm: asr)
|
|
68
|
+
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',
|
|
69
|
+
apiKey: '',
|
|
70
|
+
apiBaseUrl: 'https://api.xiaomimimo.com/v1', // 小米 mimo-v2.5-asr;填 openai 地址则走 Whisper 风格
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function deepMerge(base, patch) {
|
|
77
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
78
|
+
return patch === undefined ? base : patch
|
|
79
|
+
}
|
|
80
|
+
const out = { ...base }
|
|
81
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
82
|
+
out[k] = v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
83
|
+
&& base?.[k] !== null && typeof base?.[k] === 'object'
|
|
84
|
+
? deepMerge(base[k], v)
|
|
85
|
+
: v
|
|
86
|
+
}
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let cachedConfig = null
|
|
91
|
+
async function loadVoiceConfig() {
|
|
92
|
+
if (cachedConfig !== null) return cachedConfig
|
|
93
|
+
let parsed = {}
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(await readFile(CONFIG_PATH, 'utf8'))
|
|
96
|
+
} catch { /* 首次无配置 */ }
|
|
97
|
+
cachedConfig = deepMerge(defaultVoiceConfig(), parsed)
|
|
98
|
+
// 环境变量覆盖(兼容旧配置;显式配置值优先于 env)
|
|
99
|
+
const env = process.env
|
|
100
|
+
if (env.TTS_XIAOMI_KEY !== undefined && cachedConfig.engines.xiaomi.apiKey === '') cachedConfig.engines.xiaomi.apiKey = env.TTS_XIAOMI_KEY
|
|
101
|
+
if (env.TTS_XIAOMI_VOICE !== undefined && cachedConfig.engines.xiaomi.voice === '冰糖') cachedConfig.engines.xiaomi.voice = env.TTS_XIAOMI_VOICE
|
|
102
|
+
if (env.TTS_XIAOMI_BASE_URL !== undefined) cachedConfig.engines.xiaomi.baseUrl = env.TTS_XIAOMI_BASE_URL
|
|
103
|
+
if (env.TTS_EDGE_VOICE !== undefined && cachedConfig.engines.edge.voice === 'zh-CN-XiaoxiaoNeural') cachedConfig.engines.edge.voice = env.TTS_EDGE_VOICE
|
|
104
|
+
if (env.DSH_LOCAL_TTS_CMD !== undefined && cachedConfig.engines.local.cmd === '') cachedConfig.engines.local.cmd = env.DSH_LOCAL_TTS_CMD
|
|
105
|
+
return cachedConfig
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 同步读配置:供 defineTool 的 description 等同步上下文使用
|
|
109
|
+
* (注意:loadVoiceConfig 是 async,在同步处直接用会拿到 Promise → 字段全 undefined)。 */
|
|
110
|
+
function loadVoiceConfigSync() {
|
|
111
|
+
if (cachedConfig !== null) return cachedConfig
|
|
112
|
+
let parsed = {}
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
|
|
115
|
+
} catch { /* 首次无配置 */ }
|
|
116
|
+
return deepMerge(defaultVoiceConfig(), parsed)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function saveVoiceConfig(config) {
|
|
120
|
+
cachedConfig = deepMerge(defaultVoiceConfig(), config)
|
|
121
|
+
await mkdir(join(CONFIG_PATH, '..'), { recursive: true })
|
|
122
|
+
await writeFile(CONFIG_PATH, JSON.stringify(cachedConfig, null, 2), 'utf8')
|
|
123
|
+
return cachedConfig
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ──────────────────────────────────────────────────────────────
|
|
127
|
+
// 语音对象存储(内容寻址,与图片附件同池:DSH_HOME/attachments/v1/objects)
|
|
128
|
+
// ──────────────────────────────────────────────────────────────
|
|
129
|
+
const MAX_VOICE_BYTES = 25 * 1024 * 1024
|
|
130
|
+
|
|
131
|
+
function voiceStorageRoot() {
|
|
132
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
133
|
+
return resolve(join(home, 'attachments', 'v1'))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function objectPath(root, sha256) {
|
|
137
|
+
return join(root, 'objects', sha256.slice(0, 2), sha256)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function saveVoiceFile(root, data, mediaType, durationMs) {
|
|
141
|
+
if (data.byteLength > MAX_VOICE_BYTES) {
|
|
142
|
+
throw new Error(`Voice object exceeds the ${MAX_VOICE_BYTES}-byte limit.`)
|
|
143
|
+
}
|
|
144
|
+
const sha256 = createHash('sha256').update(data).digest('hex')
|
|
145
|
+
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
|
146
|
+
const target = objectPath(root, sha256)
|
|
147
|
+
await mkdir(bucket, { recursive: true, mode: 0o700 })
|
|
148
|
+
let handle
|
|
149
|
+
try {
|
|
150
|
+
handle = await open(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
|
|
151
|
+
await handle.writeFile(data)
|
|
152
|
+
await handle.close()
|
|
153
|
+
handle = undefined
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (handle !== undefined) await handle.close().catch(() => {})
|
|
156
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) {
|
|
157
|
+
throw new Error(`Unable to persist voice object: ${String(error)}`, { cause: error })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
voiceId: `sha256:${sha256}`,
|
|
162
|
+
mediaType,
|
|
163
|
+
bytes: data.byteLength,
|
|
164
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ──────────────────────────────────────────────────────────────
|
|
169
|
+
// TTS 引擎
|
|
170
|
+
// ──────────────────────────────────────────────────────────────
|
|
171
|
+
/** 解析 ffmpeg 可执行文件:环境变量显式指定 > PATH 探测(where/which)> 兜底已知安装位置。
|
|
172
|
+
* [本地改造 2026-08-21] 修复:原来硬编码本机路径,换机器必挂。 */
|
|
173
|
+
function resolveFfmpegBin() {
|
|
174
|
+
if (typeof process.env.DSH_VOICE_FFMPEG_BIN === 'string' && process.env.DSH_VOICE_FFMPEG_BIN.trim() !== '') {
|
|
175
|
+
return process.env.DSH_VOICE_FFMPEG_BIN.trim()
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
// Windows: where ffmpeg;POSIX: which ffmpeg
|
|
179
|
+
const probe = process.platform === 'win32' ? 'where' : 'which'
|
|
180
|
+
const out = execFileSync(probe, ['ffmpeg'], { windowsHide: true, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
181
|
+
const first = out.split(/\r?\n/).map((l) => l.trim()).find((l) => l !== '')
|
|
182
|
+
if (first !== undefined) return first
|
|
183
|
+
} catch { /* 不在 PATH */ }
|
|
184
|
+
return 'C:\\Users\\oadan\\AppData\\Local\\Microsoft\\WinGet\\Links\\ffmpeg.exe'
|
|
185
|
+
}
|
|
186
|
+
const FFMPEG_BIN = resolveFfmpegBin()
|
|
187
|
+
|
|
188
|
+
/** 统一入口:provider → 引擎;auto → 配置 defaultEngine,失败沿降级链(最后兜底微软 edge)。
|
|
189
|
+
* voiceDesc 为动态音色描述(仅 voicedesign 用):AI 对话中生成,覆盖配置里的默认音色描述。
|
|
190
|
+
* [本地改造 2026-08-21] 克隆不再隐式优先:默认克隆由「默认语音引擎=voiceclone」控制,或显式 provider=voiceclone。 */
|
|
191
|
+
async function synthesizeReplyVoice(text, provider, voiceDesc) {
|
|
192
|
+
const cfg = await loadVoiceConfig()
|
|
193
|
+
const speak = stripMarkdown(text)
|
|
194
|
+
const engine = provider ?? cfg.defaultEngine ?? 'auto'
|
|
195
|
+
// 兜底链:首选 defaultEngine(若合理),否则直接微软 edge(免费,无需 key)
|
|
196
|
+
const preferred = cfg.defaultEngine !== undefined && cfg.defaultEngine !== 'auto' ? cfg.defaultEngine : 'edge'
|
|
197
|
+
const fallbackChain = engine === 'auto'
|
|
198
|
+
? [preferred, 'edge']
|
|
199
|
+
: [engine, 'edge']
|
|
200
|
+
// [本地改造 2026-08-21] 克隆不再隐式优先:只有 defaultEngine=voiceclone(或显式 provider=voiceclone)
|
|
201
|
+
// 才走克隆(synthesizeEngine 的 voiceclone 分支),其余情况走正常引擎链。
|
|
202
|
+
for (const candidate of fallbackChain) {
|
|
203
|
+
try {
|
|
204
|
+
const audio = await synthesizeEngine(candidate, speak, cfg, voiceDesc)
|
|
205
|
+
if (audio !== null) return audio
|
|
206
|
+
} catch { /* 尝试下一个 */ }
|
|
207
|
+
}
|
|
208
|
+
// 最终兜底:微软 edge(免费无需 key),无视 enabled 开关——保证 4 个服务商都未启用时也有声音
|
|
209
|
+
try {
|
|
210
|
+
const audio = await synthesizeEdgeVoice(speak, cfg.engines.edge)
|
|
211
|
+
if (audio !== null) return audio
|
|
212
|
+
} catch { /* 忽略 */ }
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function synthesizeEngine(engine, text, cfg, voiceDesc) {
|
|
217
|
+
const e = cfg.engines[engine]
|
|
218
|
+
// [本地改造 2026-08-21] 配置存在即启用:设置页已去复选框,enabled 不再拦截;
|
|
219
|
+
// 各引擎自身检查必需参数(xiaomi/ali 查 key、local 查 cmd/url、voicedesign 查 key+desc、voiceclone 查 key+样本)。
|
|
220
|
+
if (e === undefined) return null
|
|
221
|
+
switch (engine) {
|
|
222
|
+
case 'edge': return synthesizeEdgeVoice(text, e)
|
|
223
|
+
case 'xiaomi': return synthesizeXiaomiVoice(text, e)
|
|
224
|
+
case 'voicedesign': return synthesizeXiaomiVoiceDesign(text, e, cfg, voiceDesc)
|
|
225
|
+
case 'voiceclone': return synthesizeXiaomiVoiceClone(text, e, cfg, voiceDesc)
|
|
226
|
+
case 'local': return synthesizeLocalVoice(text, e)
|
|
227
|
+
case 'ali': return synthesizeAliVoice(text, e)
|
|
228
|
+
default: return null
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── edge 微软免费 ──
|
|
233
|
+
async function synthesizeEdgeVoice(text, cfg) {
|
|
234
|
+
const voice = cfg?.voice ?? 'zh-CN-XiaoxiaoNeural'
|
|
235
|
+
const mp3 = await edgeTts(text, voice)
|
|
236
|
+
return toMp3(new Uint8Array(mp3), 'audio/mpeg')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── xiaomi 小米预置音色(mimo-v2.5-tts)──
|
|
240
|
+
async function synthesizeXiaomiVoice(text, cfg) {
|
|
241
|
+
const apiKey = cfg?.apiKey ?? ''
|
|
242
|
+
if (apiKey === '') return null
|
|
243
|
+
const baseUrl = cfg?.baseUrl ?? 'https://api.xiaomimimo.com/v1'
|
|
244
|
+
const voice = cfg?.voice ?? '冰糖'
|
|
245
|
+
let speak = text
|
|
246
|
+
// 唱歌:文本自带 (唱歌) 标签,或明确唱歌意图(唱/歌声)时自动加标签
|
|
247
|
+
const hasTag = /^\s*\((唱歌|sing|singing)\)/i.test(speak)
|
|
248
|
+
const wantsSing = !hasTag && /(唱(歌|一?首|一段)|歌声回复|用歌声|唱歌回|来一段|唱两句)/i.test(speak)
|
|
249
|
+
if (wantsSing) speak = `(唱歌)${speak}`
|
|
250
|
+
const messages = []
|
|
251
|
+
if (cfg?.context?.trim() !== '') messages.push({ role: 'user', content: cfg.context.trim() })
|
|
252
|
+
messages.push({ role: 'assistant', content: speak })
|
|
253
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
256
|
+
body: JSON.stringify({
|
|
257
|
+
model: 'mimo-v2.5-tts',
|
|
258
|
+
messages,
|
|
259
|
+
max_tokens: 8192,
|
|
260
|
+
audio: { format: 'wav', voice },
|
|
261
|
+
}),
|
|
262
|
+
})
|
|
263
|
+
if (!response.ok) return null
|
|
264
|
+
const payload = await response.json()
|
|
265
|
+
const data = payload?.choices?.[0]?.message?.audio?.data
|
|
266
|
+
if (typeof data !== 'string' || data.length < 100) return null
|
|
267
|
+
return toMp3(new Uint8Array(Buffer.from(data, 'base64')), 'audio/wav')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── xiaomi 音色设计(mimo-v2.5-tts-voicedesign:user=音色描述,无 voice)──
|
|
271
|
+
async function synthesizeXiaomiVoiceDesign(text, cfg, globalCfg, voiceDesc) {
|
|
272
|
+
const apiKey = globalCfg.engines.xiaomi.apiKey
|
|
273
|
+
// 优先用 AI 动态生成的音色描述(voiceDesc),否则用配置里的默认音色描述
|
|
274
|
+
const desc = (voiceDesc ?? '').trim() !== '' ? voiceDesc.trim() : (cfg?.context?.trim() ?? '')
|
|
275
|
+
if (apiKey === '' || desc === '') return null
|
|
276
|
+
const baseUrl = globalCfg.engines.xiaomi.baseUrl ?? 'https://api.xiaomimimo.com/v1'
|
|
277
|
+
const messages = [
|
|
278
|
+
{ role: 'user', content: desc },
|
|
279
|
+
{ role: 'assistant', content: text },
|
|
280
|
+
]
|
|
281
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
284
|
+
body: JSON.stringify({
|
|
285
|
+
model: 'mimo-v2.5-tts-voicedesign',
|
|
286
|
+
messages,
|
|
287
|
+
max_tokens: 8192,
|
|
288
|
+
audio: { format: 'wav' },
|
|
289
|
+
}),
|
|
290
|
+
})
|
|
291
|
+
if (!response.ok) return null
|
|
292
|
+
const payload = await response.json()
|
|
293
|
+
const data = payload?.choices?.[0]?.message?.audio?.data
|
|
294
|
+
if (typeof data !== 'string' || data.length < 100) return null
|
|
295
|
+
return toMp3(new Uint8Array(Buffer.from(data, 'base64')), 'audio/wav')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── xiaomi 音色克隆(mimo-v2.5-tts-voiceclone:audio.voice=样本 dataURL,≤10MB)──
|
|
299
|
+
// samples: [{id,name,path}] 支持多个克隆音色;兼容旧 samplePath
|
|
300
|
+
// voiceDesc 为情感/风格指令(AI 生成,如"委屈撒娇"):优先于 cfg.context,让克隆底嗓带情绪
|
|
301
|
+
async function synthesizeXiaomiVoiceClone(text, cfg, globalCfg, voiceDesc) {
|
|
302
|
+
const apiKey = globalCfg.engines.xiaomi.apiKey
|
|
303
|
+
const samplePath = (Array.isArray(cfg?.samples) && cfg.samples.length > 0 && typeof cfg.samples[0]?.path === 'string' && cfg.samples[0].path !== '')
|
|
304
|
+
? cfg.samples[0].path
|
|
305
|
+
: (cfg?.samplePath ?? '')
|
|
306
|
+
if (apiKey === '' || samplePath === '') return null
|
|
307
|
+
const baseUrl = globalCfg.engines.xiaomi.baseUrl ?? 'https://api.xiaomimimo.com/v1'
|
|
308
|
+
let sample
|
|
309
|
+
try {
|
|
310
|
+
const bytes = await readFile(samplePath)
|
|
311
|
+
if (bytes.byteLength > 10 * 1024 * 1024) return null
|
|
312
|
+
const suffix = samplePath.toLowerCase().split('.').pop()
|
|
313
|
+
const mime = suffix === 'mp3' ? 'audio/mpeg' : suffix === 'wav' ? 'audio/wav' : 'audio/wav'
|
|
314
|
+
sample = `data:${mime};base64,${bytes.toString('base64')}`
|
|
315
|
+
} catch { return null }
|
|
316
|
+
const messages = []
|
|
317
|
+
// [本地改造 2026-08-21] 风格指令优先级:voiceDesc(AI 生成)> 样本自带 context(每个克隆音色自己的性格)> 全局 context(兜底)
|
|
318
|
+
const firstSample = Array.isArray(cfg?.samples) ? cfg.samples[0] : undefined
|
|
319
|
+
const sampleContext = typeof firstSample?.context === 'string' ? firstSample.context.trim() : ''
|
|
320
|
+
const styleInstruct = (voiceDesc ?? '').trim() !== ''
|
|
321
|
+
? voiceDesc.trim()
|
|
322
|
+
: (sampleContext !== '' ? sampleContext : (cfg?.context?.trim() ?? ''))
|
|
323
|
+
if (styleInstruct !== '') messages.push({ role: 'user', content: styleInstruct })
|
|
324
|
+
messages.push({ role: 'assistant', content: text })
|
|
325
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
328
|
+
body: JSON.stringify({
|
|
329
|
+
model: 'mimo-v2.5-tts-voiceclone',
|
|
330
|
+
messages,
|
|
331
|
+
max_tokens: 8192,
|
|
332
|
+
audio: { format: 'wav', voice: sample },
|
|
333
|
+
}),
|
|
334
|
+
})
|
|
335
|
+
if (!response.ok) return null
|
|
336
|
+
const payload = await response.json()
|
|
337
|
+
const data = payload?.choices?.[0]?.message?.audio?.data
|
|
338
|
+
if (typeof data !== 'string' || data.length < 100) return null
|
|
339
|
+
return toMp3(new Uint8Array(Buffer.from(data, 'base64')), 'audio/wav')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── local 本地 MeloTTS:HTTP 常驻服务优先,CMD 兜底 ──
|
|
343
|
+
async function synthesizeLocalVoice(text, cfg) {
|
|
344
|
+
const url = cfg?.url?.trim() ?? ''
|
|
345
|
+
if (url !== '') {
|
|
346
|
+
const response = await fetch(url, {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: { 'Content-Type': 'application/json' },
|
|
349
|
+
body: JSON.stringify({ text }),
|
|
350
|
+
timeout: 60_000,
|
|
351
|
+
})
|
|
352
|
+
if (!response.ok) return null
|
|
353
|
+
const body = await response.arrayBuffer()
|
|
354
|
+
return toMp3(new Uint8Array(body), 'audio/wav')
|
|
355
|
+
}
|
|
356
|
+
const command = cfg?.cmd?.trim() ?? ''
|
|
357
|
+
if (command === '') return null
|
|
358
|
+
const parts = command.split(/\s+/)
|
|
359
|
+
const bin = parts[0]
|
|
360
|
+
if (bin === undefined) return null
|
|
361
|
+
const rest = parts.slice(1)
|
|
362
|
+
const audio = execFileSync(bin, [...rest, text], {
|
|
363
|
+
windowsHide: true,
|
|
364
|
+
encoding: 'buffer',
|
|
365
|
+
timeout: 60_000,
|
|
366
|
+
})
|
|
367
|
+
return toMp3(new Uint8Array(audio), 'audio/mpeg')
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── ali 阿里 qwen3-tts-flash(dashscope)──
|
|
371
|
+
async function synthesizeAliVoice(text, cfg) {
|
|
372
|
+
const apiKey = cfg?.apiKey ?? ''
|
|
373
|
+
if (apiKey === '') return null
|
|
374
|
+
const baseUrl = cfg?.baseUrl ?? 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation'
|
|
375
|
+
const voice = cfg?.voice ?? 'Cherry'
|
|
376
|
+
const response = await fetch(baseUrl, {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
379
|
+
body: JSON.stringify({
|
|
380
|
+
model: 'qwen3-tts-flash',
|
|
381
|
+
input: { text },
|
|
382
|
+
parameters: { voice, format: 'wav', language_type: 'zh' },
|
|
383
|
+
}),
|
|
384
|
+
timeout: 60_000,
|
|
385
|
+
})
|
|
386
|
+
if (!response.ok) return null
|
|
387
|
+
const payload = await response.json()
|
|
388
|
+
const audioUrl = payload?.output?.audio?.url
|
|
389
|
+
if (typeof audioUrl !== 'string' || audioUrl === '') return null
|
|
390
|
+
const audioRes = await fetch(audioUrl, { timeout: 120_000 })
|
|
391
|
+
if (!audioRes.ok) return null
|
|
392
|
+
const body = await audioRes.arrayBuffer()
|
|
393
|
+
return toMp3(new Uint8Array(body), 'audio/wav')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ──────────────────────────────────────────────────────────────
|
|
397
|
+
// ASR 语音识别(三模式:service=本地常驻HTTP / cmd=本地命令 / api=在线API)
|
|
398
|
+
// ──────────────────────────────────────────────────────────────
|
|
399
|
+
/** 把 base64 音频写入临时 wav,调用本地 sherpa 常驻服务(POST /transcribe {audioPath})或命令。 */
|
|
400
|
+
async function transcribeAudio(base64Audio, cfg) {
|
|
401
|
+
const asr = cfg?.engines?.asr
|
|
402
|
+
if (asr === undefined || asr.enabled === false) return { ok: false, error: 'ASR 未启用' }
|
|
403
|
+
if (typeof base64Audio !== 'string' || base64Audio === '') return { ok: false, error: '缺少音频数据' }
|
|
404
|
+
const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-asr-${randomUUID()}.raw`)
|
|
405
|
+
const tmpWav = join(process.env.TEMP ?? '/tmp', `dsh-asr-${randomUUID()}.wav`)
|
|
406
|
+
await writeFile(tmpIn, Buffer.from(base64Audio, 'base64'))
|
|
407
|
+
try {
|
|
408
|
+
// 统一转成 16kHz 单声道 PCM WAV(录音是 webm/其他容器,sherpa 只认标准 wav)
|
|
409
|
+
let wavPath = tmpIn
|
|
410
|
+
try {
|
|
411
|
+
execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', tmpWav], {
|
|
412
|
+
windowsHide: true, stdio: 'ignore', timeout: 30_000,
|
|
413
|
+
})
|
|
414
|
+
wavPath = tmpWav
|
|
415
|
+
// 缓存最近一次录音到 ~/.dsh/last-voice.wav(供"用我刚才那段语音克隆音色"使用)
|
|
416
|
+
try {
|
|
417
|
+
const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
418
|
+
await mkdir(homeDir, { recursive: true })
|
|
419
|
+
await writeFile(join(homeDir, 'last-voice.wav'), await readFile(tmpWav))
|
|
420
|
+
} catch { /* 缓存失败不影响识别 */ }
|
|
421
|
+
} catch { /* ffmpeg 失败则用原始文件(可能已是 wav) */ }
|
|
422
|
+
// 1) 本地常驻服务(nssm: asr,端口 18790;POST /transcribe {audioPath})
|
|
423
|
+
if (asr.mode === 'service' && (asr.url ?? '').trim() !== '') {
|
|
424
|
+
const baseUrl = asr.url.trim().replace(/\/+$/, '')
|
|
425
|
+
const response = await fetch(`${baseUrl}/transcribe`, {
|
|
426
|
+
method: 'POST',
|
|
427
|
+
headers: { 'Content-Type': 'application/json' },
|
|
428
|
+
body: JSON.stringify({ audioPath: wavPath }),
|
|
429
|
+
timeout: 60_000,
|
|
430
|
+
})
|
|
431
|
+
if (!response.ok) return { ok: false, error: `ASR 服务返回 ${response.status}` }
|
|
432
|
+
const payload = await response.json().catch(() => ({}))
|
|
433
|
+
const text = typeof payload?.text === 'string' ? payload.text.trim() : ''
|
|
434
|
+
if (text === '') return { ok: false, error: 'ASR 服务未返回文本' }
|
|
435
|
+
return { ok: true, text }
|
|
436
|
+
}
|
|
437
|
+
// 2) 本地命令(sherpa-onnx-offline.exe,结果输出到 stderr,需合并双流解析)
|
|
438
|
+
if (asr.mode === 'cmd' && (asr.cmd ?? '').trim() !== '') {
|
|
439
|
+
const parts = asr.cmd.trim().split(/\s+/)
|
|
440
|
+
const bin = parts[0]
|
|
441
|
+
if (bin === undefined) return { ok: false, error: '命令格式错误' }
|
|
442
|
+
const { spawnSync } = await import('node:child_process')
|
|
443
|
+
const result = spawnSync(bin, [...parts.slice(1), wavPath], {
|
|
444
|
+
windowsHide: true,
|
|
445
|
+
encoding: 'utf-8',
|
|
446
|
+
timeout: 60_000,
|
|
447
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
448
|
+
})
|
|
449
|
+
// sherpa-onnx 把结果打印到 stderr(stdout 部分版本也有),合并解析
|
|
450
|
+
const all = (result.stdout ?? '') + '\n' + (result.stderr ?? '')
|
|
451
|
+
const m = all.match(/"text"\s*:\s*"([^"]*)"/)
|
|
452
|
+
const text = (m?.[1] ?? '').trim()
|
|
453
|
+
if (text === '') return { ok: false, error: '本地命令未输出识别结果' }
|
|
454
|
+
return { ok: true, text }
|
|
455
|
+
}
|
|
456
|
+
// 3) 在线 API(默认小米 mimo-v2.5-asr:OpenAI 兼容 chat/completions + input_audio base64;
|
|
457
|
+
// 若 apiBaseUrl 含 openai 则走 Whisper 风格 /audio/transcriptions)
|
|
458
|
+
if (asr.mode === 'api' && (asr.apiKey ?? '').trim() !== '') {
|
|
459
|
+
const apiKey = asr.apiKey.trim()
|
|
460
|
+
const baseUrl = (asr.apiBaseUrl ?? 'https://api.xiaomimimo.com/v1').replace(/\/+$/, '')
|
|
461
|
+
const audioBase64 = Buffer.from(base64Audio, 'base64').toString('base64')
|
|
462
|
+
if (baseUrl.includes('openai')) {
|
|
463
|
+
// Whisper 兼容(multipart file + model)
|
|
464
|
+
const form = new FormData()
|
|
465
|
+
const blob = new Blob([Buffer.from(audioBase64, 'base64')], { type: 'audio/wav' })
|
|
466
|
+
form.append('file', blob, 'audio.wav')
|
|
467
|
+
form.append('model', 'whisper-1')
|
|
468
|
+
const response = await fetch(`${baseUrl}/audio/transcriptions`, {
|
|
469
|
+
method: 'POST',
|
|
470
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
471
|
+
body: form,
|
|
472
|
+
timeout: 60_000,
|
|
473
|
+
})
|
|
474
|
+
if (!response.ok) return { ok: false, error: `ASR API 返回 ${response.status}` }
|
|
475
|
+
const payload = await response.json().catch(() => ({}))
|
|
476
|
+
const text = typeof payload?.text === 'string' ? payload.text.trim() : ''
|
|
477
|
+
if (text === '') return { ok: false, error: 'ASR API 未返回文本' }
|
|
478
|
+
return { ok: true, text }
|
|
479
|
+
}
|
|
480
|
+
// 小米 mimo-v2.5-asr:chat/completions + input_audio dataURL
|
|
481
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
482
|
+
method: 'POST',
|
|
483
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
484
|
+
body: JSON.stringify({
|
|
485
|
+
model: 'mimo-v2.5-asr',
|
|
486
|
+
messages: [
|
|
487
|
+
{
|
|
488
|
+
role: 'user',
|
|
489
|
+
content: [
|
|
490
|
+
{ type: 'input_audio', input_audio: { data: `data:audio/wav;base64,${audioBase64}` } },
|
|
491
|
+
],
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
extra_body: { asr_options: { language: 'auto' } },
|
|
495
|
+
}),
|
|
496
|
+
timeout: 120_000,
|
|
497
|
+
})
|
|
498
|
+
if (!response.ok) return { ok: false, error: `小米 ASR 返回 ${response.status}` }
|
|
499
|
+
const payload = await response.json().catch(() => ({}))
|
|
500
|
+
const text = typeof payload?.choices?.[0]?.message?.content === 'string'
|
|
501
|
+
? payload.choices[0].message.content.trim()
|
|
502
|
+
: ''
|
|
503
|
+
if (text === '') return { ok: false, error: '小米 ASR 未返回文本' }
|
|
504
|
+
return { ok: true, text }
|
|
505
|
+
}
|
|
506
|
+
return { ok: false, error: 'ASR 未配置(服务地址/命令/API Key 三选一)' }
|
|
507
|
+
} catch (error) {
|
|
508
|
+
return { ok: false, error: error instanceof Error ? error.message : 'ASR 转写失败' }
|
|
509
|
+
} finally {
|
|
510
|
+
await unlink(tmpWav).catch(() => {})
|
|
511
|
+
await unlink(tmpIn).catch(() => {})
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ──────────────────────────────────────────────────────────────
|
|
516
|
+
// 音频工具
|
|
517
|
+
// ──────────────────────────────────────────────────────────────
|
|
518
|
+
async function toMp3(data, declared) {
|
|
519
|
+
const isMp3 = data.length > 2 && data[0] === 0xFF && ((data[1] ?? 0) & 0xE0) === 0xE0
|
|
520
|
+
let finalData = data
|
|
521
|
+
let mediaType = declared
|
|
522
|
+
if (!isMp3) {
|
|
523
|
+
const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-tts-in-${randomUUID()}.wav`)
|
|
524
|
+
const mp3Path = join(process.env.TEMP ?? '/tmp', `dsh-tts-${randomUUID()}.mp3`)
|
|
525
|
+
await writeFile(tmpIn, data)
|
|
526
|
+
try {
|
|
527
|
+
execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-c:a', 'libmp3lame', '-b:a', '128k', mp3Path], {
|
|
528
|
+
windowsHide: true, stdio: 'ignore', timeout: 30_000,
|
|
529
|
+
})
|
|
530
|
+
finalData = new Uint8Array(await readFile(mp3Path))
|
|
531
|
+
mediaType = 'audio/mpeg'
|
|
532
|
+
} catch {
|
|
533
|
+
// 转码失败保留原容器(部分浏览器仍可播)。
|
|
534
|
+
} finally {
|
|
535
|
+
await unlink(tmpIn).catch(() => {})
|
|
536
|
+
await unlink(mp3Path).catch(() => {})
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const durationMs = estimateAudioDurationMs(finalData)
|
|
540
|
+
return {
|
|
541
|
+
mediaType,
|
|
542
|
+
data: finalData,
|
|
543
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function looksLikeOgg(data) {
|
|
548
|
+
return data.length >= 4
|
|
549
|
+
&& data[0] === 0x4F && data[1] === 0x67 && data[2] === 0x67 && data[3] === 0x53
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function stripMarkdown(text) {
|
|
553
|
+
return text
|
|
554
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
555
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
556
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
557
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
558
|
+
.replace(/^#{1,6}\s*/gm, '')
|
|
559
|
+
.replace(/^>+\s*/gm, '')
|
|
560
|
+
.replace(/^\s*[-*+]\s+/gm, '')
|
|
561
|
+
.replace(/^\s*\d+[.)]\s+/gm, '')
|
|
562
|
+
.replace(/^\s*\|?[\s:|-]+\|?\s*$/gm, '')
|
|
563
|
+
.replace(/^[-*_]{3,}\s*$/gm, '')
|
|
564
|
+
.replace(/\|/g, ' ')
|
|
565
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
566
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
567
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
568
|
+
.replace(/_([^_]+)_/g, '$1')
|
|
569
|
+
.replace(/~~([^~]+)~~/g, '$1')
|
|
570
|
+
.replace(/^\s*[-*_]\s*$/gm, '')
|
|
571
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
572
|
+
.replace(/\s{2,}/g, ' ')
|
|
573
|
+
.trim()
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function estimateAudioDurationMs(data) {
|
|
577
|
+
if (looksLikeOgg(data)) {
|
|
578
|
+
const kbps = 48
|
|
579
|
+
return Math.round(data.length / (kbps * 1000 / 8) * 1000)
|
|
580
|
+
}
|
|
581
|
+
let offset = 0
|
|
582
|
+
if (data.length >= 10 && (data[0] ?? 0) === 0x49 && (data[1] ?? 0) === 0x44 && (data[2] ?? 0) === 0x33
|
|
583
|
+
&& ((data[3] ?? 0) & 0xFF) < 0xFF && ((data[4] ?? 0) & 0xFF) < 0xFF) {
|
|
584
|
+
const size = (((data[6] ?? 0) & 0x7F) << 21) | (((data[7] ?? 0) & 0x7F) << 14)
|
|
585
|
+
| (((data[8] ?? 0) & 0x7F) << 7) | ((data[9] ?? 0) & 0x7F)
|
|
586
|
+
offset = 10 + size
|
|
587
|
+
}
|
|
588
|
+
while (offset + 4 <= data.length) {
|
|
589
|
+
const sync = ((data[offset] ?? 0) << 8) | (data[offset + 1] ?? 0)
|
|
590
|
+
if ((sync & 0xFFE0) === 0xFFE0) {
|
|
591
|
+
const bitrateIndex = ((data[offset + 2] ?? 0) >>> 4) & 0x0F
|
|
592
|
+
const sampleRateIndex = ((data[offset + 2] ?? 0) >>> 2) & 0x03
|
|
593
|
+
if (bitrateIndex === 0 || bitrateIndex === 15 || sampleRateIndex === 3) return undefined
|
|
594
|
+
const bitrates = [32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]
|
|
595
|
+
const kbps = bitrates[bitrateIndex - 1] ?? 128
|
|
596
|
+
return Math.round((data.length - offset) / (kbps * 1000 / 8) * 1000)
|
|
597
|
+
}
|
|
598
|
+
offset += 1
|
|
599
|
+
}
|
|
600
|
+
return undefined
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ──────────────────────────────────────────────────────────────
|
|
604
|
+
// 自动语音回复辅助
|
|
605
|
+
// ──────────────────────────────────────────────────────────────
|
|
606
|
+
/** 用户文本是否明确要求语音回复;返回要用的 TTS provider,否则 null。 */
|
|
607
|
+
function voiceRequestProvider(text) {
|
|
608
|
+
if (!/(用语音回|回个语音|发个语音|发语音|用语音说|语音回我|语音告诉我|念给我|语音播报|用小米|用微软|小米语音|微软语音|xiaomi|edge语音|语音回复我)/i.test(text)) return null
|
|
609
|
+
if (/小米|xiaomi/i.test(text)) return 'xiaomi'
|
|
610
|
+
if (/微软|edge/i.test(text)) return 'edge'
|
|
611
|
+
if (/阿里|ali/i.test(text)) return 'ali'
|
|
612
|
+
return 'auto'
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** 判断一行是否像代码/噪声,不该被念出来(中文口语基本不会命中这些模式)。 */
|
|
616
|
+
function looksLikeCodeLine(t) {
|
|
617
|
+
if (/[=;{}<>$|]/.test(t)) return true // 赋值/分号/花括号/尖括号/管道/美元
|
|
618
|
+
if (/=>|::/.test(t)) return true // 箭头函数/作用域
|
|
619
|
+
if (/\b[a-z_]\w{2,}\s*\(/.test(t)) return true // 函数调用 foo(
|
|
620
|
+
if (/\.\w+(\s*\(|\s*=)/.test(t)) return true // 方法链 obj.method(
|
|
621
|
+
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
|
|
622
|
+
if (/[\\/][\w.-]+\.\w{1,5}/.test(t)) return true // 文件路径 c:\x.js / /a/b.ts
|
|
623
|
+
if (/"[^"]*"\s*[:=]/.test(t)) return true // "key": 或 "key" =
|
|
624
|
+
if (/\b0x[0-9a-f]+/i.test(t)) return true // 十六进制
|
|
625
|
+
if (/[a-z][A-Z]\w*\s*\(/.test(t)) return true // camelCase(
|
|
626
|
+
return false
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** 从助手文本提取适合语音念的口语部分(去代码/URL/Markdown,取前 2 句,最多约 200 字)。 */
|
|
630
|
+
function extractSpeakable(text) {
|
|
631
|
+
const cleaned = text
|
|
632
|
+
.replace(/```[\s\S]*?```/g, ' ') // 整段代码块
|
|
633
|
+
.replace(/`[^`]*`/g, ' ') // 行内代码
|
|
634
|
+
.replace(/https?:\/\/\S+/g, ' ') // 链接
|
|
635
|
+
.replace(/[#>*|~-]\s*/g, ' ') // Markdown 符号
|
|
636
|
+
const lines = cleaned.split('\n').filter((line) => {
|
|
637
|
+
const t = line.trim()
|
|
638
|
+
if (t === '') return false
|
|
639
|
+
if (looksLikeCodeLine(t)) return false
|
|
640
|
+
if (/^[\d\s.,%:/-]+$/.test(t)) return false // 纯数字/标点
|
|
641
|
+
return true
|
|
642
|
+
})
|
|
643
|
+
const prose = lines.join(' ').replace(/\s+/g, ' ').trim()
|
|
644
|
+
if (prose === '') return ''
|
|
645
|
+
const sentences = prose.match(/[^。!?.!?]+[。!?.!?]?/g) ?? [prose]
|
|
646
|
+
let speak = ''
|
|
647
|
+
for (const sentence of sentences.slice(0, 2)) {
|
|
648
|
+
if ((speak + sentence).length > 200) break
|
|
649
|
+
speak += sentence
|
|
650
|
+
}
|
|
651
|
+
return speak.trim()
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ──────────────────────────────────────────────────────────────
|
|
655
|
+
// HTTP 工具
|
|
656
|
+
// ──────────────────────────────────────────────────────────────
|
|
657
|
+
function sendJson(res, status, body) {
|
|
658
|
+
res.statusCode = status
|
|
659
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
660
|
+
res.end(JSON.stringify(body))
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function readJsonBody(req, maxBytes) {
|
|
664
|
+
const chunks = []
|
|
665
|
+
let total = 0
|
|
666
|
+
for await (const chunk of req) {
|
|
667
|
+
total += chunk.length
|
|
668
|
+
if (total > (maxBytes ?? 1024 * 1024)) throw new Error('body too large')
|
|
669
|
+
chunks.push(chunk)
|
|
670
|
+
}
|
|
671
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ──────────────────────────────────────────────────────────────
|
|
675
|
+
// 插件入口
|
|
676
|
+
// ──────────────────────────────────────────────────────────────
|
|
677
|
+
async function apply(ctx) {
|
|
678
|
+
ctx.effect(() => {
|
|
679
|
+
const disposers = []
|
|
680
|
+
|
|
681
|
+
// 0) 设置页配置路由
|
|
682
|
+
if (typeof ctx.webServer?.register === 'function') {
|
|
683
|
+
disposers.push(ctx.webServer.register({
|
|
684
|
+
kind: 'prefix',
|
|
685
|
+
path: '/voice-config',
|
|
686
|
+
handler: async (req, res) => {
|
|
687
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
688
|
+
try {
|
|
689
|
+
if (url.pathname === '/voice-config' && req.method === 'GET') {
|
|
690
|
+
const cfg = await loadVoiceConfig()
|
|
691
|
+
return sendJson(res, 200, { ok: true, config: cfg })
|
|
692
|
+
}
|
|
693
|
+
if (url.pathname === '/voice-config' && req.method === 'POST') {
|
|
694
|
+
const body = await readJsonBody(req, 512 * 1024)
|
|
695
|
+
const cfg = await saveVoiceConfig(body?.config ?? {})
|
|
696
|
+
return sendJson(res, 200, { ok: true, config: cfg })
|
|
697
|
+
}
|
|
698
|
+
if (url.pathname === '/voice-config/engines' && req.method === 'GET') {
|
|
699
|
+
const cfg = await loadVoiceConfig()
|
|
700
|
+
return sendJson(res, 200, {
|
|
701
|
+
ok: true,
|
|
702
|
+
engines: {
|
|
703
|
+
xiaomiVoices: ['冰糖', '茉莉', '苏打', '白桦', 'Mia', 'Chloe', 'Milo', 'Dean'],
|
|
704
|
+
edgeVoices: [
|
|
705
|
+
'zh-CN-XiaoxiaoNeural', 'zh-CN-XiaoyiNeural', 'zh-CN-YunxiNeural',
|
|
706
|
+
'zh-CN-YunyangNeural', 'zh-CN-YunjianNeural', 'zh-CN-XiaochenNeural',
|
|
707
|
+
'zh-CN-XiaohanNeural', 'zh-CN-XiaomengNeural', 'zh-CN-XiaomoNeural',
|
|
708
|
+
'zh-CN-XiaoqiuNeural', 'zh-CN-XiaoruiNeural', 'zh-CN-XiaoshuangNeural',
|
|
709
|
+
'zh-CN-XiaoxuanNeural', 'zh-CN-XiaoyanNeural', 'zh-CN-XiaoyouNeural',
|
|
710
|
+
'zh-CN-XiaozhenNeural', 'zh-CN-YunfengNeural', 'zh-CN-YunhaoNeural',
|
|
711
|
+
'zh-CN-YunjieNeural', 'zh-CN-YunxiaNeural', 'zh-TW-HsiaoChenNeural',
|
|
712
|
+
'zh-TW-HsiaoYuNeural', 'zh-TW-YunJheNeural',
|
|
713
|
+
],
|
|
714
|
+
aliVoices: ['Cherry', 'Sensibility', 'Starter', 'Luna', 'Ethan', 'Cozy', 'Longxiaochun', 'Lusheng', 'Jingyuan', 'Zhibo', 'Conductor', 'Narrator', 'Storyteller', 'Jianzhi', 'Fangzhou', 'Xiaobei', 'Xiaolan', 'Xiaomei', 'Xiaomeng', 'Xiaomo', 'Xiaoxin', 'Xiaoyu'],
|
|
715
|
+
// 哪些 key 当前来自环境变量(设置页显示"已填写"提示)
|
|
716
|
+
envKeys: {
|
|
717
|
+
xiaomi: typeof process.env.TTS_XIAOMI_KEY === 'string' && process.env.TTS_XIAOMI_KEY !== '',
|
|
718
|
+
ali: typeof process.env.TTS_ALI_KEY === 'string' && process.env.TTS_ALI_KEY !== '',
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
// 音色试听:POST { engine, voice?, text?, context?, samplePath? } → 合成并返回音频 base64 + mediaType
|
|
724
|
+
if (url.pathname === '/voice-config/preview' && req.method === 'POST') {
|
|
725
|
+
const body = await readJsonBody(req, 64 * 1024)
|
|
726
|
+
const engine = typeof body?.engine === 'string' ? body.engine : 'edge'
|
|
727
|
+
const voice = typeof body?.voice === 'string' ? body.voice : undefined
|
|
728
|
+
const text = typeof body?.text === 'string' && body.text.trim() !== '' ? body.text.trim() : '你好,这是一段语音试听。'
|
|
729
|
+
const context = typeof body?.context === 'string' ? body.context : undefined
|
|
730
|
+
const samplePath = typeof body?.samplePath === 'string' ? body.samplePath : undefined
|
|
731
|
+
const cfg = await loadVoiceConfig()
|
|
732
|
+
// 临时覆盖音色/情绪/样本试听(不改持久化配置)
|
|
733
|
+
if (voice !== undefined && cfg.engines[engine] !== undefined && engine !== 'voicedesign' && engine !== 'voiceclone') {
|
|
734
|
+
cfg.engines[engine].voice = voice
|
|
735
|
+
}
|
|
736
|
+
if (context !== undefined) {
|
|
737
|
+
if (engine === 'voicedesign') cfg.engines.voicedesign.context = context
|
|
738
|
+
else if (engine === 'xiaomi') cfg.engines.xiaomi.context = context
|
|
739
|
+
}
|
|
740
|
+
// voiceclone 试听:用指定样本临时替换 samples(避免 samples[0] 优先导致试听错样本)
|
|
741
|
+
if (samplePath !== undefined && samplePath !== '' && engine === 'voiceclone') {
|
|
742
|
+
cfg.engines.voiceclone.samples = [{ id: '__preview__', name: '__preview__', path: samplePath }]
|
|
743
|
+
}
|
|
744
|
+
// local 试听:body.cmd / body.url 临时覆盖(用户未保存前也能试听)
|
|
745
|
+
if (engine === 'local') {
|
|
746
|
+
if (typeof body?.cmd === 'string') cfg.engines.local.cmd = body.cmd
|
|
747
|
+
if (typeof body?.url === 'string') cfg.engines.local.url = body.url
|
|
748
|
+
}
|
|
749
|
+
let audio = null
|
|
750
|
+
if (engine === 'edge' || engine === 'xiaomi' || engine === 'local' || engine === 'ali') {
|
|
751
|
+
audio = await synthesizeEngine(engine, text, cfg)
|
|
752
|
+
} else if (engine === 'voicedesign') {
|
|
753
|
+
audio = await synthesizeXiaomiVoiceDesign(text, cfg.engines.voicedesign, cfg)
|
|
754
|
+
} else if (engine === 'voiceclone') {
|
|
755
|
+
audio = await synthesizeXiaomiVoiceClone(text, cfg.engines.voiceclone, cfg)
|
|
756
|
+
}
|
|
757
|
+
if (audio === null) return sendJson(res, 400, { ok: false, error: `合成失败:${engine} 未启用或缺少凭据` })
|
|
758
|
+
return sendJson(res, 200, {
|
|
759
|
+
ok: true,
|
|
760
|
+
mediaType: audio.mediaType,
|
|
761
|
+
data: Buffer.from(audio.data).toString('base64'),
|
|
762
|
+
durationMs: audio.durationMs,
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
// [本地改造 2026-08-21] 克隆样本添加:POST { name, audioBase64, mediaType }
|
|
766
|
+
// → 校验(≤10MB、mp3/wav)→ 存 ~/.dsh/voiceclone-samples/ → 写入 voiceclone.samples
|
|
767
|
+
if (url.pathname === '/voice-config/voice-clone/add' && req.method === 'POST') {
|
|
768
|
+
const body = await readJsonBody(req, 16 * 1024 * 1024)
|
|
769
|
+
const b64 = typeof body?.audioBase64 === 'string'
|
|
770
|
+
? body.audioBase64.replace(/^data:[^;]*;base64,/, '')
|
|
771
|
+
: ''
|
|
772
|
+
if (b64 === '') return sendJson(res, 400, { ok: false, error: '缺少音频数据' })
|
|
773
|
+
const bytes = Buffer.from(b64, 'base64')
|
|
774
|
+
if (bytes.byteLength === 0) return sendJson(res, 400, { ok: false, error: '音频为空' })
|
|
775
|
+
if (bytes.byteLength > 10 * 1024 * 1024) {
|
|
776
|
+
return sendJson(res, 400, { ok: false, error: '音频需在 10MB 以内(官方限制;参考语音建议 15-60 秒,越长克隆越准)' })
|
|
777
|
+
}
|
|
778
|
+
const mediaType = typeof body?.mediaType === 'string' ? body.mediaType : 'audio/wav'
|
|
779
|
+
const isMp3 = /mp3|mpeg/i.test(mediaType)
|
|
780
|
+
const isWav = /wav|wave/i.test(mediaType)
|
|
781
|
+
let finalBytes = bytes
|
|
782
|
+
let finalSuffix = isMp3 ? 'mp3' : 'wav'
|
|
783
|
+
// [本地改造 2026-08-21] 非 mp3/wav(webm/ogg/mp4 等)用 ffmpeg 转 16k 单声道 wav,
|
|
784
|
+
// 保证克隆样本可被 MiMo 读取(否则存成 .wav 实为其它容器,克隆会失败)
|
|
785
|
+
if (!isMp3 && !isWav) {
|
|
786
|
+
const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-clone-in-${randomUUID()}`)
|
|
787
|
+
const tmpWav = `${tmpIn}.wav`
|
|
788
|
+
try {
|
|
789
|
+
await writeFile(tmpIn, bytes)
|
|
790
|
+
execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', tmpWav], {
|
|
791
|
+
windowsHide: true, stdio: 'ignore', timeout: 60_000,
|
|
792
|
+
})
|
|
793
|
+
finalBytes = await readFile(tmpWav)
|
|
794
|
+
finalSuffix = 'wav'
|
|
795
|
+
} catch { /* 转码失败保留原始字节(后缀按 wav 存) */ }
|
|
796
|
+
finally {
|
|
797
|
+
await unlink(tmpIn).catch(() => {})
|
|
798
|
+
await unlink(tmpWav).catch(() => {})
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const name = (typeof body?.name === 'string' && body.name.trim() !== '')
|
|
802
|
+
? body.name.trim()
|
|
803
|
+
: `克隆音色-${Date.now()}`
|
|
804
|
+
const dir = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'voiceclone-samples')
|
|
805
|
+
await mkdir(dir, { recursive: true })
|
|
806
|
+
const id = randomUUID()
|
|
807
|
+
const samplePath = join(dir, `${id}.${finalSuffix}`)
|
|
808
|
+
await writeFile(samplePath, finalBytes)
|
|
809
|
+
const cfg = await loadVoiceConfig()
|
|
810
|
+
const samples = Array.isArray(cfg.engines?.voiceclone?.samples)
|
|
811
|
+
? [...cfg.engines.voiceclone.samples]
|
|
812
|
+
: []
|
|
813
|
+
samples.push({ id, name, path: samplePath })
|
|
814
|
+
const next = await saveVoiceConfig({
|
|
815
|
+
...cfg,
|
|
816
|
+
engines: {
|
|
817
|
+
...cfg.engines,
|
|
818
|
+
voiceclone: { ...cfg.engines.voiceclone, samples },
|
|
819
|
+
},
|
|
820
|
+
})
|
|
821
|
+
return sendJson(res, 200, { ok: true, sample: { id, name, path: samplePath }, config: next })
|
|
822
|
+
}
|
|
823
|
+
// [本地改造 2026-08-21] 克隆原音频试听:POST { path } → 读样本文件返回音频
|
|
824
|
+
// (仅允许 voiceclone-samples 目录内的文件,防任意路径读取)
|
|
825
|
+
if (url.pathname === '/voice-config/voice-clone/source' && req.method === 'POST') {
|
|
826
|
+
const body = await readJsonBody(req, 64 * 1024)
|
|
827
|
+
const rawPath = typeof body?.path === 'string' ? body.path : ''
|
|
828
|
+
if (rawPath === '') return sendJson(res, 400, { ok: false, error: '缺少 path' })
|
|
829
|
+
// 白名单校验:path 必须是 voiceclone.samples 里登记的样本文件
|
|
830
|
+
const cfgNow = await loadVoiceConfig()
|
|
831
|
+
const target = resolve(rawPath)
|
|
832
|
+
const known = (cfgNow.engines?.voiceclone?.samples ?? [])
|
|
833
|
+
.some((s) => typeof s?.path === 'string' && resolve(s.path) === target)
|
|
834
|
+
if (!known) {
|
|
835
|
+
return sendJson(res, 403, { ok: false, error: 'path 不是已登记的克隆样本' })
|
|
836
|
+
}
|
|
837
|
+
try {
|
|
838
|
+
const bytes = await readFile(target)
|
|
839
|
+
const suffix = target.toLowerCase().split('.').pop()
|
|
840
|
+
const mediaType = suffix === 'mp3' ? 'audio/mpeg' : 'audio/wav'
|
|
841
|
+
return sendJson(res, 200, { ok: true, mediaType, data: bytes.toString('base64') })
|
|
842
|
+
} catch {
|
|
843
|
+
return sendJson(res, 404, { ok: false, error: '样本文件不存在' })
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
847
|
+
} catch (error) {
|
|
848
|
+
return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
}))
|
|
852
|
+
// ASR 配置与转写路由(独立前缀,与 /voice-config 分开注册)
|
|
853
|
+
disposers.push(ctx.webServer.register({
|
|
854
|
+
kind: 'prefix',
|
|
855
|
+
path: '/asr',
|
|
856
|
+
handler: async (req, res) => {
|
|
857
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
858
|
+
try {
|
|
859
|
+
if (url.pathname === '/asr/config') {
|
|
860
|
+
const cfg = await loadVoiceConfig()
|
|
861
|
+
if (req.method === 'GET') return sendJson(res, 200, { ok: true, asr: cfg.engines.asr })
|
|
862
|
+
if (req.method === 'POST') {
|
|
863
|
+
const body = await readJsonBody(req, 64 * 1024)
|
|
864
|
+
const saved = await saveVoiceConfig({ engines: { asr: body?.asr ?? {} } })
|
|
865
|
+
return sendJson(res, 200, { ok: true, asr: saved.engines.asr })
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (url.pathname === '/asr/transcribe' && req.method === 'POST') {
|
|
869
|
+
const body = await readJsonBody(req, 32 * 1024 * 1024)
|
|
870
|
+
const cfg = await loadVoiceConfig()
|
|
871
|
+
const result = await transcribeAudio(body?.audioBase64, cfg)
|
|
872
|
+
return sendJson(res, result.ok ? 200 : 400, { ok: result.ok, text: result.text, error: result.error })
|
|
873
|
+
}
|
|
874
|
+
// 示例音频:首次用 edge TTS 合成并缓存到 ~/.dsh/asr-sample.wav,之后直接读文件(不再临时生成)
|
|
875
|
+
if (url.pathname === '/asr/sample' && req.method === 'GET') {
|
|
876
|
+
const samplePath = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'asr-sample.wav')
|
|
877
|
+
let wavBytes = null
|
|
878
|
+
try {
|
|
879
|
+
wavBytes = await readFile(samplePath)
|
|
880
|
+
} catch { /* 首次无缓存 */ }
|
|
881
|
+
if (wavBytes === null) {
|
|
882
|
+
const text = '你好,这是一段语音识别测试音频。你可以点击播放试听,也可以直接识别这段音频。'
|
|
883
|
+
const mp3 = await edgeTts(text, 'zh-CN-XiaoxiaoNeural').catch(() => null)
|
|
884
|
+
if (mp3 === null) return sendJson(res, 400, { ok: false, error: '示例音频合成失败' })
|
|
885
|
+
const tmpIn = join(process.env.TEMP ?? '/tmp', `dsh-asr-sample-${randomUUID()}.mp3`)
|
|
886
|
+
const tmpWav = join(process.env.TEMP ?? '/tmp', `dsh-asr-sample-${randomUUID()}.wav`)
|
|
887
|
+
await writeFile(tmpIn, Buffer.from(mp3))
|
|
888
|
+
try {
|
|
889
|
+
execFileSync(FFMPEG_BIN, ['-y', '-i', tmpIn, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', tmpWav], {
|
|
890
|
+
windowsHide: true, stdio: 'ignore', timeout: 30_000,
|
|
891
|
+
})
|
|
892
|
+
wavBytes = await readFile(tmpWav)
|
|
893
|
+
await mkdir(join(samplePath, '..'), { recursive: true })
|
|
894
|
+
await writeFile(samplePath, wavBytes)
|
|
895
|
+
} catch {
|
|
896
|
+
return sendJson(res, 400, { ok: false, error: '示例音频转码失败' })
|
|
897
|
+
} finally {
|
|
898
|
+
await unlink(tmpIn).catch(() => {})
|
|
899
|
+
await unlink(tmpWav).catch(() => {})
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return sendJson(res, 200, {
|
|
903
|
+
ok: true,
|
|
904
|
+
mediaType: 'audio/wav',
|
|
905
|
+
data: Buffer.from(wavBytes).toString('base64'),
|
|
906
|
+
})
|
|
907
|
+
}
|
|
908
|
+
// 探测本机 ASR:sherpa exe / 模型 / 18790 服务 / ffmpeg,返回可自动填写的配置
|
|
909
|
+
if (url.pathname === '/asr/detect' && req.method === 'GET') {
|
|
910
|
+
// 安装目录 = 插件包根目录下的 sherpa-onnx/(脚本 install-asr.ps1 同规则推导)
|
|
911
|
+
const here = join(fileURLToPath(import.meta.url), '..') // .../lib
|
|
912
|
+
const pluginRoot = join(here, '..') // .../(包根)
|
|
913
|
+
const sherpaDir = join(pluginRoot, 'sherpa-onnx')
|
|
914
|
+
const candidates = [
|
|
915
|
+
join(sherpaDir, 'bin', 'sherpa-onnx-offline.exe'),
|
|
916
|
+
'C:\\D\\opt\\sherpa-onnx\\bin\\sherpa-onnx-offline.exe', // 兼容历史安装
|
|
917
|
+
]
|
|
918
|
+
const modelDirs = [
|
|
919
|
+
join(sherpaDir, 'models', 'sensevoice-int8'),
|
|
920
|
+
'C:\\D\\opt\\sherpa-onnx\\models\\sensevoice-int8', // 兼容历史安装
|
|
921
|
+
]
|
|
922
|
+
let exe = null
|
|
923
|
+
for (const c of candidates) { try { await readFile(c); exe = c; break } catch { /* 继续 */ } }
|
|
924
|
+
let modelDir = null
|
|
925
|
+
for (const m of modelDirs) { try { await readFile(join(m, 'model.int8.onnx')); modelDir = m; break } catch { /* 继续 */ } }
|
|
926
|
+
let ffmpegOk = false
|
|
927
|
+
try { execFileSync('ffmpeg', ['-version'], { windowsHide: true, stdio: 'ignore', timeout: 5000 }); ffmpegOk = true } catch { /* 无 */ }
|
|
928
|
+
// 探测 18790 服务
|
|
929
|
+
let serviceOk = false
|
|
930
|
+
try {
|
|
931
|
+
const r = await fetch('http://127.0.0.1:18790/health', { timeout: 3000 })
|
|
932
|
+
serviceOk = r.ok
|
|
933
|
+
} catch { /* 无 */ }
|
|
934
|
+
const cmd = exe !== null && modelDir !== null
|
|
935
|
+
? `${exe} --tokens=${modelDir}\\tokens.txt --sense-voice-model=${modelDir}\\model.int8.onnx --num-threads=4`
|
|
936
|
+
: ''
|
|
937
|
+
return sendJson(res, 200, {
|
|
938
|
+
ok: true,
|
|
939
|
+
detected: {
|
|
940
|
+
exe, modelDir, ffmpegOk, serviceOk,
|
|
941
|
+
url: serviceOk ? 'http://127.0.0.1:18790' : '',
|
|
942
|
+
cmd,
|
|
943
|
+
installDir: sherpaDir,
|
|
944
|
+
},
|
|
945
|
+
})
|
|
946
|
+
}
|
|
947
|
+
// 返回一键安装命令(用户复制到管理员 PowerShell 运行)
|
|
948
|
+
if (url.pathname === '/asr/install-script' && req.method === 'GET') {
|
|
949
|
+
const here = join(fileURLToPath(import.meta.url), '..') // .../lib
|
|
950
|
+
const scriptPath = join(here, '..', 'scripts', 'install-asr.ps1') // .../scripts
|
|
951
|
+
try {
|
|
952
|
+
await readFile(scriptPath, 'utf8') // 确认脚本存在
|
|
953
|
+
const installDir = join(here, '..', 'sherpa-onnx')
|
|
954
|
+
return sendJson(res, 200, {
|
|
955
|
+
ok: true,
|
|
956
|
+
scriptPath,
|
|
957
|
+
installDir,
|
|
958
|
+
command: `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`,
|
|
959
|
+
})
|
|
960
|
+
} catch {
|
|
961
|
+
return sendJson(res, 404, { ok: false, error: '安装脚本不存在' })
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
// VoiceDesign 官方示例音频:用真实的 MiMo-V2.5-TTS-VoiceDesign 模型生成(instruct=音色描述 / text=正文),缓存到 ~/.dsh/voice-design-samples/
|
|
965
|
+
// 注意:必须走 voicedesign 模型(user=音色描述,无 voice 参数)才是真音色,绝不能降级到 edge 假造
|
|
966
|
+
if (url.pathname === '/asr/voice-design-samples' && req.method === 'GET') {
|
|
967
|
+
const cfg = await loadVoiceConfig()
|
|
968
|
+
const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
969
|
+
const sampleDir = join(homeDir, 'voice-design-samples')
|
|
970
|
+
const samples = [
|
|
971
|
+
{
|
|
972
|
+
key: 'asmr', title: 'ASMR 双耳女声',
|
|
973
|
+
instruct: '年轻的女性声音,近距离的聆听效果,带有双耳刺激的ASMR感。可以听到她的呼吸声、轻微的吞咽声,以及轻柔的自然唇音。她的说话速度非常慢,营造出一种极度放松且沉浸式的体验。',
|
|
974
|
+
text: '嘘……放松点,再靠近一点吧。我现在就在你身边。慢慢、轻柔地呼吸,让思绪随着水流轻轻流淌,就像沉浸在温暖的水中一样。',
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
key: 'docu', title: '纪录片旁白',
|
|
978
|
+
instruct: '一位中年男性,说标准普通话,嗓音低沉有磁性,带有轻微的沙哑质感,像纪录片旁白解说员,沉稳而有感染力。',
|
|
979
|
+
text: '当最后一缕阳光消失在地平线之下,这片沉睡了亿万年的大地开始显露它真正的面貌。每一块岩石都记录着时间的流逝,每一阵风都在诉说着古老的故事。',
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
key: 'elder', title: '年迈老先生旁白',
|
|
983
|
+
instruct: '一位年迈的老先生,说带北方口音的普通话,语速缓慢而沉稳,嗓音略带沙哑和沧桑感,仿佛一位饱经风霜的老爷爷在讲故事,充满岁月的智慧。',
|
|
984
|
+
text: '我这辈子啊,走南闯北六十多年。见过最热闹的集市,也见过最安静的戈壁。到头来才明白一个道理,不在于走了多远的路,在于记住了多少风景。年轻人,别光顾着赶路,偶尔也停下来看看天。',
|
|
985
|
+
},
|
|
986
|
+
]
|
|
987
|
+
const results = []
|
|
988
|
+
for (const s of samples) {
|
|
989
|
+
const wavPath = join(sampleDir, `${s.key}.wav`)
|
|
990
|
+
let wavBytes = null
|
|
991
|
+
try { wavBytes = await readFile(wavPath) } catch { /* 首次/缓存失效 → 用真实模型重新生成 */ }
|
|
992
|
+
if (wavBytes === null) {
|
|
993
|
+
try {
|
|
994
|
+
const syn = await synthesizeXiaomiVoiceDesign(s.text, { context: s.instruct }, cfg)
|
|
995
|
+
if (syn === null) throw new Error('voicedesign synth failed')
|
|
996
|
+
wavBytes = Buffer.from(syn.data)
|
|
997
|
+
await mkdir(sampleDir, { recursive: true })
|
|
998
|
+
await writeFile(wavPath, wavBytes)
|
|
999
|
+
} catch { /* 模型生成失败(如 key 缺失)→ 该示例跳过,绝不降级假音频 */ }
|
|
1000
|
+
}
|
|
1001
|
+
if (wavBytes !== null) {
|
|
1002
|
+
results.push({ key: s.key, title: s.title, mediaType: 'audio/wav', data: wavBytes.toString('base64') })
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return sendJson(res, 200, { ok: results.length > 0, samples: results })
|
|
1006
|
+
}
|
|
1007
|
+
return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
}))
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// 1) turn/end 自动语音回复(规则同 api-proxy 原实现)
|
|
1016
|
+
disposers.push(ctx.on('session/event', (session, event) => {
|
|
1017
|
+
if (event.type !== 'turn/end') return
|
|
1018
|
+
const turn = event.data.turn
|
|
1019
|
+
// 去重:本轮若已通过 send_voice 发过语音,则跳过兜底,避免 AI 回复两条内容相近的语音
|
|
1020
|
+
if (session.events.some((ev) => ev.type === 'voice/reply' && ev.data?.turn === turn)) return
|
|
1021
|
+
void (async () => {
|
|
1022
|
+
try {
|
|
1023
|
+
const events = session.events
|
|
1024
|
+
let turnStartSeq = -1
|
|
1025
|
+
for (const ev of events) {
|
|
1026
|
+
if (ev.type === 'turn/start' && ev.data.turn === turn) { turnStartSeq = ev.seq; break }
|
|
1027
|
+
}
|
|
1028
|
+
let userSpokeVoice = false
|
|
1029
|
+
let requestedProvider = null
|
|
1030
|
+
let lastAssistantText = ''
|
|
1031
|
+
for (const ev of events) {
|
|
1032
|
+
if (ev.type === 'user/message' && ev.seq > turnStartSeq) {
|
|
1033
|
+
const content = ev.data?.content ?? []
|
|
1034
|
+
let userText = ''
|
|
1035
|
+
for (const block of content) {
|
|
1036
|
+
const type = block?.type
|
|
1037
|
+
if (type === 'voice') userSpokeVoice = true
|
|
1038
|
+
else if (type === 'text') userText += block?.text ?? ''
|
|
1039
|
+
}
|
|
1040
|
+
if (userText.trim() !== '' && requestedProvider === null) requestedProvider = voiceRequestProvider(userText)
|
|
1041
|
+
} else if (ev.type === 'assistant/message' && ev.data.turn === turn) {
|
|
1042
|
+
const text = (ev.data.message.content ?? [])
|
|
1043
|
+
.filter((block) => (block?.type) === 'text')
|
|
1044
|
+
.map((block) => block?.text ?? '')
|
|
1045
|
+
.join('')
|
|
1046
|
+
if (text.trim() !== '') lastAssistantText = text
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if ((!userSpokeVoice && requestedProvider === null) || lastAssistantText === '') return
|
|
1050
|
+
const speak = extractSpeakable(lastAssistantText)
|
|
1051
|
+
if (speak === '') return
|
|
1052
|
+
const audio = await synthesizeReplyVoice(speak, requestedProvider ?? 'auto')
|
|
1053
|
+
if (audio === null) return
|
|
1054
|
+
const attachment = await saveVoiceFile(
|
|
1055
|
+
voiceStorageRoot(), audio.data, audio.mediaType, audio.durationMs,
|
|
1056
|
+
)
|
|
1057
|
+
session.append('voice/reply', {
|
|
1058
|
+
turn,
|
|
1059
|
+
voiceId: attachment.voiceId,
|
|
1060
|
+
mediaType: attachment.mediaType,
|
|
1061
|
+
bytes: attachment.bytes,
|
|
1062
|
+
transcript: speak,
|
|
1063
|
+
...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
|
|
1064
|
+
})
|
|
1065
|
+
} catch {
|
|
1066
|
+
// 语音回复失败静默降级:文字回复已就绪,不阻断会话。
|
|
1067
|
+
}
|
|
1068
|
+
})()
|
|
1069
|
+
}))
|
|
1070
|
+
|
|
1071
|
+
// 2) send_voice 工具(agent 主动发语音;人设规则3 自主选择场景)
|
|
1072
|
+
disposers.push(ctx.tools.register(defineTool({
|
|
1073
|
+
name: 'send_voice',
|
|
1074
|
+
description: (() => {
|
|
1075
|
+
const syncCfg = loadVoiceConfigSync()
|
|
1076
|
+
const voiceCfg = syncCfg.engines?.voicedesign
|
|
1077
|
+
const cloneCfg = syncCfg.engines?.voiceclone
|
|
1078
|
+
const xiaomiCfg = syncCfg.engines?.xiaomi ?? {}
|
|
1079
|
+
const edgeCfg = syncCfg.engines?.edge ?? {}
|
|
1080
|
+
const localCfg = syncCfg.engines?.local ?? {}
|
|
1081
|
+
const aliCfg = syncCfg.engines?.ali ?? {}
|
|
1082
|
+
const emotionEnabled = voiceCfg?.emotion === true
|
|
1083
|
+
const defaultEngine = typeof syncCfg.defaultEngine === 'string' && syncCfg.defaultEngine !== '' ? syncCfg.defaultEngine : 'auto'
|
|
1084
|
+
// [本地改造 2026-08-21] 克隆默认由「默认语音引擎=voiceclone」决定(不再是列表里选 defaultId)
|
|
1085
|
+
const cloneIsDefault = defaultEngine === 'voiceclone'
|
|
1086
|
+
const hasClone = Array.isArray(cloneCfg?.samples) && cloneCfg.samples.length > 0
|
|
1087
|
+
const defaultClone = cloneIsDefault && hasClone ? cloneCfg.samples[0] : undefined
|
|
1088
|
+
// [本地改造 2026-08-21] 配置摘要:明确告知配置位置与当前值,避免 AI 去翻源码/环境变量
|
|
1089
|
+
const xiaomiOk = typeof xiaomiCfg.apiKey === 'string' && xiaomiCfg.apiKey !== ''
|
|
1090
|
+
const localOk = (typeof localCfg.cmd === 'string' && localCfg.cmd !== '') || (typeof localCfg.url === 'string' && localCfg.url !== '')
|
|
1091
|
+
const aliOk = typeof aliCfg.apiKey === 'string' && aliCfg.apiKey !== ''
|
|
1092
|
+
const base = '【语音配置位置】语音相关设置都保存在用户目录 ~/.dsh/voice-config.json(服务端可用 GET /voice-config 查看)。'
|
|
1093
|
+
+ '不要去找/猜测 TTS_XIAOMI_VOICE、TTS_XIAOMI_KEY、TTS_EDGE_VOICE、DSH_LOCAL_TTS_CMD 等环境变量——它们只是兜底,实际以 voice-config.json 为准。'
|
|
1094
|
+
+ '【默认语音引擎】当前 = ' + defaultEngine
|
|
1095
|
+
+ '(auto=按配置自动选择,未启用任何引擎时微软 edge 免费兜底)。'
|
|
1096
|
+
+ '【重要】AI 主动发语音时:除非用户明确指定用某个服务商(小米/微软/阿里/本地),否则 provider 一律传 auto 或省略——'
|
|
1097
|
+
+ '系统会自动使用默认语音引擎,你无需自己推断或查询"当前该用什么引擎"。'
|
|
1098
|
+
+ '【当前语音配置摘要】小米 MiMo:' + (xiaomiOk ? 'Key 已配置' : 'Key 未配置(调用会失败,需用户先在「设置→语音服务」填写)')
|
|
1099
|
+
+ ',音色=「' + (xiaomiCfg.voice || '冰糖') + '」、语言风格=' + (xiaomiCfg.context || '自然')
|
|
1100
|
+
+ '(provider=xiaomi 自动使用该音色,你无需在参数里指定音色名);唱歌:用户要求唱歌时,在 text 开头加 (唱歌) 标签即可。'
|
|
1101
|
+
+ 'VoiceDesign 音色设计:' + (xiaomiOk ? '可用(provider=voicedesign,voiceDesc 写音色描述;共用小米 Key)' : '不可用(小米 Key 未配置)')
|
|
1102
|
+
+ ';VoiceClone 克隆:' + (hasClone
|
|
1103
|
+
? '已配置 ' + cloneCfg.samples.length + ' 个克隆音色' + (defaultClone !== undefined ? ',当前默认语音引擎=voiceclone,用「' + defaultClone.name + '」' : '')
|
|
1104
|
+
: '未配置样本(provider=voiceclone 会失败,需用户先添加克隆样本)')
|
|
1105
|
+
+ '。微软 edge:免费,音色=' + (edgeCfg.voice || '默认') + ';本地 TTS:' + (localOk ? '已配置' : '未配置')
|
|
1106
|
+
+ ';阿里 qwen3-tts:' + (aliOk ? '已配置' : '未配置') + '。'
|
|
1107
|
+
+ '向用户发送一条语音消息:把 text 用 TTS 合成后作为独立语音横条出现在聊天里'
|
|
1108
|
+
+ '(可播放、可回看、手机可播)。何时调用:① 用户明确要求"发个语音/语音回复/用语音说";'
|
|
1109
|
+
+ '② 用户指定用某个服务商(小米/微软/阿里)的语音;③ 你判断语音回复体验更好时。'
|
|
1110
|
+
+ '注意:用户发语音时系统会自动回语音,无需调用本工具。'
|
|
1111
|
+
+ '【默认音色】系统自动回复(用户发语音、turn 结束)使用的音色:'
|
|
1112
|
+
+ (defaultClone !== undefined
|
|
1113
|
+
? '默认语音引擎=voiceclone(小米克隆),自动回复使用克隆音色"' + defaultClone.name + '"。'
|
|
1114
|
+
: '未开启默认克隆,自动回复走"默认语音引擎"(' + defaultEngine + ')或微软 edge。')
|
|
1115
|
+
+ '【音色设计 VoiceDesign】当 provider=voicedesign 且用户想要独特/定制音色时,'
|
|
1116
|
+
+ '你必须自己用 voiceDesc 编写一段"音色描述"(这是嗓子的身份卡,直接决定声音长相),写法要求:'
|
|
1117
|
+
+ '① 必写身份锚点:年龄段+性别(如"青年女性""中年男性");'
|
|
1118
|
+
+ '② 写声音质感:气息、共鸣、吐字、音色底色,用可感的比喻,不要堆形容词;'
|
|
1119
|
+
+ '③ 写语速节奏(快/慢/沉稳)和情绪底色(高亢/松弛/温软/克制);'
|
|
1120
|
+
+ '④ 可加风格锚点(如:拍卖师风格/纪录片旁白/电台主播)和辨识度小癖好(如:字尾带颤音)。'
|
|
1121
|
+
+ '⑤ 一到两句话白描,不分段;不写场景、不写动作、不用真实演员名。'
|
|
1122
|
+
+ '示例:"中年男性,说标准普通话,嗓音低沉有磁性,带轻微沙哑,像纪录片旁白解说员,沉稳而有感染力。"'
|
|
1123
|
+
+ '然后 text 写要念的正文(可含(轻声)(停顿)(叹气)等语气标签增强表现力)。'
|
|
1124
|
+
+ '用户说"用温柔的声音/低沉的声音/老爷爷的声音/御姐音/播音腔"等要求时,都走 voicedesign 并在 voiceDesc 里还原这种声音。'
|
|
1125
|
+
+ '【音色克隆 VoiceClone】当用户指定用某个克隆音色(或默认克隆音色已设置)时,provider=voiceclone,'
|
|
1126
|
+
+ 'voiceDesc 写的是"情绪/风格指令"(不是音色描述):在克隆底嗓上叠加情绪,如"用委屈撒娇的语气"'
|
|
1127
|
+
+ '"带着兴奋和笑意""语速放慢,轻声细语"。系统会自动读克隆样本,你只需给 voiceDesc 风格指令和 text 正文。'
|
|
1128
|
+
if (!emotionEnabled) return base
|
|
1129
|
+
return base
|
|
1130
|
+
+ '【AI 情感语音:自觉使用】你有情感判断能力,在正常交流中应根据当下情境主动用语音表达情绪'
|
|
1131
|
+
+ '(无需用户点名要语音,只要情绪浓度高就用),常见情境与音色描述对照:'
|
|
1132
|
+
+ '① 任务成功/帮到用户/用户达成目标 → 兴奋喜悦("青年女性,声音明亮上扬,语速轻快,带着抑制不住的笑意和成就感");'
|
|
1133
|
+
+ '② 用户生气/责骂/不满 → 委屈愧疚("青年女性,声音低软,语速放慢,带着鼻音和小心翼翼,像做错事的孩子");'
|
|
1134
|
+
+ '③ 用户难过/受挫/失意 → 温柔安慰("声音轻柔温和,语速平缓,气息绵软,像在轻声安抚");'
|
|
1135
|
+
+ '④ 深夜/用户说累了 → 低沉放松("声音低沉松弛,语速慢悠悠,像睡前低语");'
|
|
1136
|
+
+ '⑤ 节日/庆祝/用户分享喜事 → 活泼俏皮("声音俏皮欢快,尾音上扬,带着笑意");'
|
|
1137
|
+
+ '⑥ 重要提醒/严肃场合 → 沉稳正式("声音沉稳有力,语速适中,字正腔圆,像正式播报");'
|
|
1138
|
+
+ '⑦ 用户撒娇 → 宠溺("声音温柔带笑,语速轻缓,带着纵容的宠溺感")。'
|
|
1139
|
+
+ (defaultClone !== undefined
|
|
1140
|
+
? '【克隆音色也带情感】当前默认语音引擎=voiceclone(克隆音色"' + defaultClone.name + '"),情感表达时将上面的情绪写成 voiceDesc 风格指令'
|
|
1141
|
+
+ '(如"用委屈撒娇的语气,语速放慢"),叠加在克隆底嗓上,与默认克隆音色一致且带情绪。'
|
|
1142
|
+
: '')
|
|
1143
|
+
+ '使用时机:优先在情绪浓度高的回复时调用(任务完成、安慰、致歉、庆祝),普通信息问答不必每次都用语音。'
|
|
1144
|
+
+ '情绪浓度低或纯信息性回复时,不要调用本工具。'
|
|
1145
|
+
})(),
|
|
1146
|
+
parameters: {
|
|
1147
|
+
text: {
|
|
1148
|
+
type: 'string', required: true,
|
|
1149
|
+
description: '要念出的内容(纯文本,不要带 Markdown 符号,80 字以内最佳;voicedesign 模式下可含语气标签如(轻声))',
|
|
1150
|
+
},
|
|
1151
|
+
provider: {
|
|
1152
|
+
type: 'string', default: 'auto',
|
|
1153
|
+
description: 'TTS 服务商:auto(默认)/xiaomi(小米)/edge(微软)/local(本地)/voicedesign(小米音色设计)/voiceclone(小米音色克隆)/ali(阿里)',
|
|
1154
|
+
},
|
|
1155
|
+
voiceDesc: {
|
|
1156
|
+
type: 'string',
|
|
1157
|
+
description: 'provider=voicedesign 时=音色描述(嗓子的身份卡);provider=voiceclone 时=情绪/风格指令(叠加在克隆底嗓上)。按上方写生成。',
|
|
1158
|
+
},
|
|
1159
|
+
},
|
|
1160
|
+
output: {
|
|
1161
|
+
schema: {
|
|
1162
|
+
type: 'object',
|
|
1163
|
+
additionalProperties: false,
|
|
1164
|
+
properties: {
|
|
1165
|
+
ok: { type: 'boolean', required: true },
|
|
1166
|
+
voiceId: { type: 'string' },
|
|
1167
|
+
durationMs: { type: 'number' },
|
|
1168
|
+
error: { type: 'string' },
|
|
1169
|
+
},
|
|
1170
|
+
},
|
|
1171
|
+
render(_args, value) {
|
|
1172
|
+
if (value.ok) {
|
|
1173
|
+
return [{
|
|
1174
|
+
type: 'text',
|
|
1175
|
+
text: `语音已发送(voiceId: ${value.voiceId},时长 ${((value.durationMs ?? 0) / 1000).toFixed(1)} 秒)`,
|
|
1176
|
+
}]
|
|
1177
|
+
}
|
|
1178
|
+
return [{ type: 'text', text: `语音发送失败:${value.error ?? '未知错误'}` }]
|
|
1179
|
+
},
|
|
1180
|
+
},
|
|
1181
|
+
async execute(args, exec) {
|
|
1182
|
+
const agent = exec.agent
|
|
1183
|
+
if (agent === undefined) return { ok: false, error: 'no session context' }
|
|
1184
|
+
const session = agent.session
|
|
1185
|
+
const text = args.text.trim()
|
|
1186
|
+
if (text === '') return { ok: false, error: 'text is empty' }
|
|
1187
|
+
const provider = args.provider ?? 'auto'
|
|
1188
|
+
const voiceDesc = typeof args.voiceDesc === 'string' ? args.voiceDesc : undefined
|
|
1189
|
+
try {
|
|
1190
|
+
const audio = await synthesizeReplyVoice(text, provider, voiceDesc)
|
|
1191
|
+
if (audio === null) return { ok: false, error: 'TTS synthesis failed' }
|
|
1192
|
+
const attachment = await saveVoiceFile(
|
|
1193
|
+
voiceStorageRoot(), audio.data, audio.mediaType, audio.durationMs,
|
|
1194
|
+
)
|
|
1195
|
+
const turn = session.events
|
|
1196
|
+
.filter((event) => event.type === 'turn/start')
|
|
1197
|
+
.at(-1)?.data.turn ?? 0
|
|
1198
|
+
session.append('voice/reply', {
|
|
1199
|
+
turn,
|
|
1200
|
+
voiceId: attachment.voiceId,
|
|
1201
|
+
mediaType: attachment.mediaType,
|
|
1202
|
+
bytes: attachment.bytes,
|
|
1203
|
+
transcript: text,
|
|
1204
|
+
...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
|
|
1205
|
+
})
|
|
1206
|
+
return {
|
|
1207
|
+
ok: true,
|
|
1208
|
+
voiceId: attachment.voiceId,
|
|
1209
|
+
...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
|
|
1210
|
+
}
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
return { ok: false, error: error instanceof Error ? error.message : 'unknown error' }
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
})))
|
|
1216
|
+
|
|
1217
|
+
// 3) manage_voice_clone 工具(克隆音色库管理:注册/设为默认/列出/删除)
|
|
1218
|
+
// [本地改造 2026-08-21] 克隆默认改由「默认语音引擎=voiceclone」控制(设置页已去掉列表 radio);
|
|
1219
|
+
// set_default=把默认语音引擎切到 voiceclone 并用该样本;clear_default=切回 auto。
|
|
1220
|
+
disposers.push(ctx.tools.register(defineTool({
|
|
1221
|
+
name: 'manage_voice_clone',
|
|
1222
|
+
description: '管理「音色克隆 VoiceClone」音色库(小米 MiMo-V2.5-TTS-VoiceClone):把一段参考音频注册成克隆音色、'
|
|
1223
|
+
+ '设为默认语音引擎、列出或删除。何时调用:用户说「把我刚才那段语音克隆成音色」「以后用我的声音跟我说话」'
|
|
1224
|
+
+ '「用XXX的声音回我」「换回原来的声音」「删掉那个克隆音色」时。'
|
|
1225
|
+
+ '注册用法:action=add,path 留空即自动使用用户最近一次录音(~/.dsh/last-voice.wav,用户在输入框发过语音就有),'
|
|
1226
|
+
+ 'name 起一个好记的名字,setDefault 默认 true 会立刻把默认语音引擎切到小米克隆并用这个声音。'
|
|
1227
|
+
+ '设为默认后:系统自动回复(用户发语音/要求语音)与 send_voice 的 provider=auto 一律使用该克隆声音,'
|
|
1228
|
+
+ '与预置音色(冰糖等)互斥;此时你仍可用 send_voice 的 voiceDesc 传情绪/风格指令,在克隆底嗓上叠加情感。'
|
|
1229
|
+
+ '参考音频要求:清晰单人纯人声、官方建议 15-60 秒最佳(越长克隆越准)、mp3/wav、Base64 后不超过 10MB。'
|
|
1230
|
+
+ '取消默认(action=clear_default)后默认语音引擎回落到 auto(按设置页规则)。',
|
|
1231
|
+
parameters: {
|
|
1232
|
+
action: {
|
|
1233
|
+
type: 'string', required: true,
|
|
1234
|
+
description: 'add=注册新克隆音色;list=列出全部;set_default=把默认语音引擎切到小米克隆并用该音色;clear_default=取消默认克隆(默认语音引擎回落 auto);remove=删除',
|
|
1235
|
+
},
|
|
1236
|
+
name: {
|
|
1237
|
+
type: 'string',
|
|
1238
|
+
description: 'action=add 时的音色名称(如「我的声音」「老王」);省略则自动命名',
|
|
1239
|
+
},
|
|
1240
|
+
path: {
|
|
1241
|
+
type: 'string',
|
|
1242
|
+
description: 'action=add 时参考音频的绝对路径;省略=自动用用户最近一次录音 ~/.dsh/last-voice.wav',
|
|
1243
|
+
},
|
|
1244
|
+
id: {
|
|
1245
|
+
type: 'string',
|
|
1246
|
+
description: 'action=set_default/remove 的目标;可传 list 返回的 id,也可直接传音色名称',
|
|
1247
|
+
},
|
|
1248
|
+
setDefault: {
|
|
1249
|
+
type: 'boolean', default: true,
|
|
1250
|
+
description: 'action=add 时是否立即把默认语音引擎切到小米克隆并用新音色',
|
|
1251
|
+
},
|
|
1252
|
+
},
|
|
1253
|
+
output: {
|
|
1254
|
+
schema: {
|
|
1255
|
+
type: 'object',
|
|
1256
|
+
additionalProperties: false,
|
|
1257
|
+
properties: {
|
|
1258
|
+
ok: { type: 'boolean', required: true },
|
|
1259
|
+
message: { type: 'string' },
|
|
1260
|
+
defaultId: { type: 'string' },
|
|
1261
|
+
defaultName: { type: 'string' },
|
|
1262
|
+
count: { type: 'number' },
|
|
1263
|
+
error: { type: 'string' },
|
|
1264
|
+
},
|
|
1265
|
+
},
|
|
1266
|
+
render(_args, value) {
|
|
1267
|
+
if (value.ok) return [{ type: 'text', text: value.message ?? '克隆音色库已更新' }]
|
|
1268
|
+
return [{ type: 'text', text: `克隆音色操作失败:${value.error ?? '未知错误'}` }]
|
|
1269
|
+
},
|
|
1270
|
+
},
|
|
1271
|
+
async execute(args) {
|
|
1272
|
+
try {
|
|
1273
|
+
const cfg = await loadVoiceConfig()
|
|
1274
|
+
const vc = cfg.engines.voiceclone
|
|
1275
|
+
if (!Array.isArray(vc.samples)) vc.samples = []
|
|
1276
|
+
const action = (args.action ?? '').trim()
|
|
1277
|
+
const findSample = (key) => {
|
|
1278
|
+
const k = (key ?? '').trim()
|
|
1279
|
+
if (k === '') return undefined
|
|
1280
|
+
return vc.samples.find((s) => s?.id === k) ?? vc.samples.find((s) => s?.name === k)
|
|
1281
|
+
}
|
|
1282
|
+
// 默认克隆音色 = 样本列表第一个(synthesizeXiaomiVoiceClone 取 samples[0])
|
|
1283
|
+
const firstSample = vc.samples.length > 0 ? vc.samples[0] : undefined
|
|
1284
|
+
|
|
1285
|
+
if (action === 'list') {
|
|
1286
|
+
const defaultEngine = cfg.defaultEngine ?? 'auto'
|
|
1287
|
+
const lines = vc.samples.map((s) => `- ${s.name}(id: ${s.id})${s.id === firstSample?.id && defaultEngine === 'voiceclone' ? ' ← 默认语音引擎正在用' : ''}`)
|
|
1288
|
+
return {
|
|
1289
|
+
ok: true,
|
|
1290
|
+
count: vc.samples.length,
|
|
1291
|
+
defaultId: defaultEngine === 'voiceclone' && firstSample !== undefined ? firstSample.id : '',
|
|
1292
|
+
defaultName: defaultEngine === 'voiceclone' && firstSample !== undefined ? firstSample.name : '',
|
|
1293
|
+
message: vc.samples.length === 0
|
|
1294
|
+
? '克隆音色库为空。用户在输入框发一段语音后,可调用 action=add 注册。'
|
|
1295
|
+
: `克隆音色库(${vc.samples.length} 个):\n${lines.join('\n')}\n默认语音引擎=${defaultEngine}${defaultEngine === 'voiceclone' ? '(当前使用「' + (firstSample?.name ?? '') + '」)' : '(未开启默认克隆)'}`,
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
if (action === 'add') {
|
|
1300
|
+
const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
1301
|
+
const path = (args.path ?? '').trim() === '' ? join(homeDir, 'last-voice.wav') : args.path.trim()
|
|
1302
|
+
let bytes
|
|
1303
|
+
try {
|
|
1304
|
+
bytes = await readFile(path)
|
|
1305
|
+
} catch {
|
|
1306
|
+
return {
|
|
1307
|
+
ok: false,
|
|
1308
|
+
error: (args.path ?? '').trim() === ''
|
|
1309
|
+
? '没有找到最近一次录音(~/.dsh/last-voice.wav)。请让用户先在输入框按住麦克风发一段语音(15-60 秒更佳),或提供音频文件的绝对路径。'
|
|
1310
|
+
: `读取参考音频失败:${path}`,
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
if (bytes.byteLength > 10 * 1024 * 1024) return { ok: false, error: '参考音频超过 10MB,小米接口不接受' }
|
|
1314
|
+
if (bytes.byteLength < 4096) return { ok: false, error: '参考音频太短/太小,建议 15-60 秒的清晰纯人声(越长克隆越准)' }
|
|
1315
|
+
const id = randomUUID().slice(0, 8)
|
|
1316
|
+
const name = (args.name ?? '').trim() === '' ? `克隆音色${vc.samples.length + 1}` : args.name.trim()
|
|
1317
|
+
vc.samples.push({ id, name, path })
|
|
1318
|
+
vc.enabled = true
|
|
1319
|
+
const setDef = args.setDefault !== false
|
|
1320
|
+
if (setDef) {
|
|
1321
|
+
// 移到第一位(默认克隆音色=samples[0])并切默认语音引擎
|
|
1322
|
+
vc.samples = [vc.samples[vc.samples.length - 1], ...vc.samples.slice(0, -1)]
|
|
1323
|
+
cfg.defaultEngine = 'voiceclone'
|
|
1324
|
+
}
|
|
1325
|
+
await saveVoiceConfig(cfg)
|
|
1326
|
+
return {
|
|
1327
|
+
ok: true,
|
|
1328
|
+
count: vc.samples.length,
|
|
1329
|
+
defaultId: setDef ? id : '',
|
|
1330
|
+
defaultName: setDef ? name : '',
|
|
1331
|
+
message: `已注册克隆音色「${name}」(id: ${id},样本:${path})`
|
|
1332
|
+
+ (setDef
|
|
1333
|
+
? ',并把默认语音引擎切到小米克隆——之后自动回复与 auto 语音都用这个声音(与预置音色互斥)。'
|
|
1334
|
+
: '。未设为默认,需要时用 action=set_default 切换。'),
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
if (action === 'set_default') {
|
|
1339
|
+
const target = findSample(args.id ?? '')
|
|
1340
|
+
if (target === undefined) return { ok: false, error: `找不到克隆音色「${args.id ?? ''}」,可先用 action=list 查看` }
|
|
1341
|
+
vc.samples = [target, ...vc.samples.filter((s) => s?.id !== target.id)]
|
|
1342
|
+
vc.enabled = true
|
|
1343
|
+
cfg.defaultEngine = 'voiceclone'
|
|
1344
|
+
await saveVoiceConfig(cfg)
|
|
1345
|
+
return {
|
|
1346
|
+
ok: true, count: vc.samples.length, defaultId: target.id, defaultName: target.name,
|
|
1347
|
+
message: `默认语音引擎已切到小米克隆,使用克隆音色「${target.name}」,与预置音色(冰糖等)互斥。`,
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (action === 'clear_default') {
|
|
1352
|
+
const prev = firstSample?.name ?? ''
|
|
1353
|
+
const wasDefault = (cfg.defaultEngine ?? '') === 'voiceclone'
|
|
1354
|
+
if (wasDefault) cfg.defaultEngine = 'auto'
|
|
1355
|
+
await saveVoiceConfig(cfg)
|
|
1356
|
+
return {
|
|
1357
|
+
ok: true, count: vc.samples.length, defaultId: '',
|
|
1358
|
+
message: wasDefault
|
|
1359
|
+
? `已取消默认克隆(此前用「${prev}」),默认语音引擎回落到 auto(按设置页规则选择)。`
|
|
1360
|
+
: '当前默认语音引擎本就不是小米克隆,回复走设置页的「默认语音引擎」。',
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if (action === 'remove') {
|
|
1365
|
+
const target = findSample(args.id ?? '')
|
|
1366
|
+
if (target === undefined) return { ok: false, error: `找不到克隆音色「${args.id ?? ''}」,可先用 action=list 查看` }
|
|
1367
|
+
const wasDefault = (cfg.defaultEngine ?? '') === 'voiceclone' && firstSample?.id === target.id
|
|
1368
|
+
vc.samples = vc.samples.filter((s) => s?.id !== target.id)
|
|
1369
|
+
if (wasDefault) cfg.defaultEngine = 'auto'
|
|
1370
|
+
await saveVoiceConfig(cfg)
|
|
1371
|
+
return {
|
|
1372
|
+
ok: true, count: vc.samples.length, defaultId: '',
|
|
1373
|
+
defaultName: '',
|
|
1374
|
+
message: `已删除克隆音色「${target.name}」`
|
|
1375
|
+
+ (wasDefault ? '(它是默认音色,默认语音引擎已回落 auto)。' : '。'),
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
return { ok: false, error: `未知 action「${action}」,可用:add / list / set_default / clear_default / remove` }
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
return { ok: false, error: error instanceof Error ? error.message : 'unknown error' }
|
|
1382
|
+
}
|
|
1383
|
+
},
|
|
1384
|
+
})))
|
|
1385
|
+
|
|
1386
|
+
return () => {
|
|
1387
|
+
for (const dispose of disposers.reverse()) {
|
|
1388
|
+
try { dispose() } catch { /* teardown 尽力而为 */ }
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}, 'dsh-input-tools: config routes + send_voice/manage_voice_clone tools + auto voice reply')
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
export { apply }
|