dsh-line-bot 0.9.6 → 0.10.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.
@@ -7,6 +7,7 @@ import fs from 'node:fs'
7
7
  import path from 'node:path'
8
8
  import { randomBytes } from 'node:crypto'
9
9
  import { nowIso, readJsonSafe, writeJsonAtomic } from './util.mjs'
10
+ import { BUILTIN_PERSONAS } from './builtin-personas.mjs'
10
11
 
11
12
  export const LOCAL_PREFIX = 'local:'
12
13
  const MAX_NAME = 60
@@ -22,9 +23,26 @@ function normalize(raw) {
22
23
  name: String(raw.name || '未命名').slice(0, MAX_NAME),
23
24
  text: String(raw.text || ''),
24
25
  createdAt: raw.createdAt || nowIso(),
26
+ builtin: raw.builtin === true,
27
+ builtinId: raw.builtinId ? String(raw.builtinId) : '',
28
+ description: String(raw.description || ''),
25
29
  }
26
30
  }
27
31
 
32
+ function seedBuiltins(personas) {
33
+ const known = new Set(personas.map((item) => item.id))
34
+ const seeds = BUILTIN_PERSONAS.map((item) => normalize({
35
+ id: `builtin-${item.id}`,
36
+ name: item.name,
37
+ text: item.text,
38
+ description: item.description,
39
+ builtin: true,
40
+ builtinId: item.id,
41
+ createdAt: nowIso(),
42
+ })).filter((item) => !known.has(item.id))
43
+ return [...personas, ...seeds]
44
+ }
45
+
28
46
  export function createLocalPersonas({ dir, logger }) {
29
47
  const file = path.join(dir, 'personas.json')
30
48
  let cache = null
@@ -38,18 +56,26 @@ export function createLocalPersonas({ dir, logger }) {
38
56
  }
39
57
  const raw = readJsonSafe(file, { version: 1, personas: [] })
40
58
  const list = Array.isArray(raw?.personas) ? raw.personas : []
41
- cache = {
42
- version: 1,
43
- personas: list
44
- .filter((item) => item && item.id && typeof item.text === 'string')
45
- .map(normalize),
59
+ let personas = list
60
+ .filter((item) => item && item.id && typeof item.text === 'string')
61
+ .map(normalize)
62
+ let builtinsSeeded = raw?.builtinsSeeded === true
63
+ if (!builtinsSeeded) {
64
+ personas = seedBuiltins(personas)
65
+ builtinsSeeded = true
66
+ try {
67
+ writeJsonAtomic(file, { version: 1, builtinsSeeded: true, personas }, 0o600)
68
+ } catch (error) {
69
+ logger?.warn?.(`寫入內建人設失敗:${error?.message || error}`)
70
+ }
46
71
  }
72
+ cache = { version: 1, builtinsSeeded, personas }
47
73
  return cache
48
74
  }
49
75
 
50
76
  function persist(next) {
51
- writeJsonAtomic(file, { version: 1, personas: next }, 0o600)
52
- cache = { version: 1, personas: next }
77
+ writeJsonAtomic(file, { version: 1, builtinsSeeded: true, personas: next }, 0o600)
78
+ cache = { version: 1, builtinsSeeded: true, personas: next }
53
79
  }
54
80
 
55
81
  function validateText(text) {
@@ -64,11 +90,12 @@ export function createLocalPersonas({ dir, logger }) {
64
90
  id: selectionId(item.id),
65
91
  localId: item.id,
66
92
  name: item.name,
67
- source: 'local',
68
- builtin: false,
93
+ source: item.builtin ? 'builtin' : 'local',
94
+ builtin: Boolean(item.builtin),
95
+ builtinId: item.builtinId || '',
69
96
  enabled: true,
70
97
  chars: item.text.length,
71
- description: '插件內建人設(不依賴外掛)',
98
+ description: item.description || (item.builtin ? '插件內建人設(繁體中文,可編輯)' : '插件自訂人設(不依賴外掛)'),
72
99
  }))
73
100
  }
74
101
 
@@ -117,6 +144,10 @@ export function createLocalPersonas({ dir, logger }) {
117
144
  update,
118
145
  remove,
119
146
  selectionId,
120
- stats: () => ({ count: read().personas.length, path: file }),
147
+ stats: () => ({
148
+ count: read().personas.length,
149
+ builtins: read().personas.filter((item) => item.builtin).length,
150
+ path: file,
151
+ }),
121
152
  }
122
153
  }
package/lib/tts.mjs ADDED
@@ -0,0 +1,540 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { execFile, execFileSync } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+ import { randomUUID } from 'node:crypto'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ export const MLX_MODEL = 'mlx-community/Fun-CosyVoice3-0.5B-2512-8bit'
12
+ export const GGUF_BACKEND = 'cosyvoice3-tts'
13
+ export const CRISPASR_VERSION = 'v0.8.49'
14
+ export const DEFAULT_INSTRUCT = '用可愛、輕快、帶點撒嬌的語氣說,語速稍慢。'
15
+
16
+ const CRISPASR_MAC_URL = `https://github.com/CrispStrobe/CrispASR/releases/download/${CRISPASR_VERSION}/crispasr-macos-arm64.tar.gz`
17
+ const CRISPASR_WIN_URL = `https://github.com/CrispStrobe/CrispASR/releases/download/${CRISPASR_VERSION}/crispasr-windows-x86_64-cpu.zip`
18
+
19
+ function isWin() {
20
+ return process.platform === 'win32'
21
+ }
22
+
23
+ function which(command) {
24
+ try {
25
+ const out = execFileSync(isWin() ? 'where' : 'which', [command], { encoding: 'utf8', timeout: 4000 })
26
+ return String(out).split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0] || ''
27
+ } catch {
28
+ return ''
29
+ }
30
+ }
31
+
32
+ function fileExists(file) {
33
+ try {
34
+ return fs.existsSync(file)
35
+ } catch {
36
+ return false
37
+ }
38
+ }
39
+
40
+ function dirExists(dir) {
41
+ try {
42
+ return fs.statSync(dir).isDirectory()
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ function pythonVersion(python) {
49
+ try {
50
+ const out = execFileSync(python, ['-c', 'import sys; print("%d.%d" % sys.version_info[:2])'], {
51
+ encoding: 'utf8',
52
+ timeout: 6000,
53
+ env: process.env,
54
+ }).trim()
55
+ const [major, minor] = out.split('.').map((value) => Number(value))
56
+ return { text: out, major, minor }
57
+ } catch {
58
+ return { text: '', major: 0, minor: 0 }
59
+ }
60
+ }
61
+
62
+ function parseDurationSeconds(text) {
63
+ const match = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(String(text || ''))
64
+ if (!match) return 0
65
+ return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3])
66
+ }
67
+
68
+ export function createTts({ dir, logger, getConfig }) {
69
+ const root = path.join(dir, 'tts')
70
+ const paths = {
71
+ root,
72
+ refDir: path.join(root, 'ref'),
73
+ outDir: path.join(root, 'out'),
74
+ jobsDir: path.join(root, 'jobs'),
75
+ hfHome: path.join(root, 'hf'),
76
+ mlxVenv: path.join(root, 'mlx312-venv'),
77
+ mlxPython: path.join(root, 'mlx312-venv', 'bin', 'python'),
78
+ mlxScript: path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'tools', 'mlx_synth.py'),
79
+ macCrisp: path.join(root, 'crispasr', 'crispasr-macos-arm64', 'crispasr'),
80
+ winCrisp: path.join(root, 'crispasr', 'windows', 'crispasr.exe'),
81
+ winDir: path.join(root, 'crispasr', 'windows'),
82
+ defaultRef: path.join(root, 'ref', 'default.wav'),
83
+ }
84
+ for (const item of [paths.root, paths.refDir, paths.outDir, paths.jobsDir, paths.hfHome]) {
85
+ try {
86
+ fs.mkdirSync(item, { recursive: true })
87
+ } catch {
88
+ /* 權限不足時留給 status 回報 */
89
+ }
90
+ }
91
+
92
+ const state = {
93
+ setup: { phase: 'idle', message: '', error: '', at: 0, startedAt: 0 },
94
+ last: null,
95
+ lastError: '',
96
+ installing: false,
97
+ }
98
+
99
+ function setSetup(phase, message, error = '') {
100
+ state.setup = {
101
+ phase,
102
+ message,
103
+ error: error ? String(error).slice(0, 800) : '',
104
+ at: Date.now(),
105
+ startedAt: state.setup.startedAt || Date.now(),
106
+ }
107
+ if (logger) {
108
+ if (error) logger.warn(`語音環境:${message}(${String(error).slice(0, 200)})`)
109
+ else logger.info(`語音環境:${message}`)
110
+ }
111
+ }
112
+
113
+ function cfgVoice() {
114
+ const cfg = getConfig ? getConfig() : {}
115
+ return cfg.voice || {}
116
+ }
117
+
118
+ function referencePath() {
119
+ const cfg = cfgVoice()
120
+ const configured = String(cfg.referencePath || '').trim()
121
+ if (configured && fileExists(configured)) return configured
122
+ if (fileExists(paths.defaultRef)) return paths.defaultRef
123
+ return ''
124
+ }
125
+
126
+ function referenceInfo() {
127
+ const file = referencePath()
128
+ if (!file) return { path: '', exists: false, seconds: 0 }
129
+ let seconds = 0
130
+ try {
131
+ const out = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file], {
132
+ encoding: 'utf8',
133
+ timeout: 8000,
134
+ })
135
+ seconds = Number(out.trim()) || 0
136
+ } catch {
137
+ seconds = 0
138
+ }
139
+ return { path: file, exists: true, seconds: Math.round(seconds * 100) / 100 }
140
+ }
141
+
142
+ function mlxModelCached() {
143
+ const hub = path.join(paths.hfHome, 'hub')
144
+ try {
145
+ const dirs = fs.readdirSync(hub).filter((name) => name.startsWith('models--mlx-community--Fun-CosyVoice3'))
146
+ for (const name of dirs) {
147
+ const snapshots = path.join(hub, name, 'snapshots')
148
+ if (!dirExists(snapshots)) continue
149
+ for (const snap of fs.readdirSync(snapshots)) {
150
+ if (fileExists(path.join(snapshots, snap, 'model.safetensors'))) return true
151
+ }
152
+ }
153
+ } catch {
154
+ /* ignore */
155
+ }
156
+ return false
157
+ }
158
+
159
+ let mlxError = ''
160
+ function mlxReady() {
161
+ if (!fileExists(paths.mlxPython)) {
162
+ mlxError = '找不到 MLX Python 環境'
163
+ return false
164
+ }
165
+ if (!fileExists(paths.mlxScript)) {
166
+ mlxError = `找不到 MLX 合成腳本:${paths.mlxScript}`
167
+ return false
168
+ }
169
+ try {
170
+ execFileSync(paths.mlxPython, ['-c', 'import mlx_audio, mlx_lm, librosa'], { stdio: 'ignore', timeout: 30000 })
171
+ mlxError = ''
172
+ return true
173
+ } catch (error) {
174
+ mlxError = String(error?.stderr || error?.message || error).slice(0, 300)
175
+ return false
176
+ }
177
+ }
178
+
179
+ function ggufBinary() {
180
+ const candidates = [isWin() ? paths.winCrisp : paths.macCrisp, which('crispasr')]
181
+ return candidates.find((item) => item && fileExists(item)) || ''
182
+ }
183
+
184
+ function ggufReady() {
185
+ const bin = ggufBinary()
186
+ if (!bin) return false
187
+ try {
188
+ execFileSync(bin, ['--version'], { stdio: 'ignore', timeout: 8000 })
189
+ return true
190
+ } catch {
191
+ return false
192
+ }
193
+ }
194
+
195
+ function detect() {
196
+ const platform = process.platform
197
+ const arch = process.arch
198
+ const preferred = platform === 'darwin' && arch === 'arm64' ? 'mlx' : 'gguf'
199
+ const ffmpeg = which('ffmpeg')
200
+ const ref = referenceInfo()
201
+ return {
202
+ platform,
203
+ arch,
204
+ preferred,
205
+ ffmpeg: { path: ffmpeg, ok: !!ffmpeg },
206
+ mlx: {
207
+ ok: mlxReady(),
208
+ python: fileExists(paths.mlxPython) ? paths.mlxPython : '',
209
+ model: MLX_MODEL,
210
+ modelCached: mlxModelCached(),
211
+ error: mlxError,
212
+ script: paths.mlxScript,
213
+ },
214
+ gguf: {
215
+ ok: ggufReady(),
216
+ binary: ggufBinary(),
217
+ backend: GGUF_BACKEND,
218
+ },
219
+ reference: ref,
220
+ setup: state.setup,
221
+ ready: (preferred === 'mlx' ? mlxReady() : ggufReady()) && !!ref.path,
222
+ }
223
+ }
224
+
225
+ function resolveProvider(cfg = cfgVoice()) {
226
+ const detected = detect()
227
+ const wanted = String(cfg.provider || 'auto')
228
+ if (wanted === 'mlx') {
229
+ if (!detected.mlx.ok) throw new Error('MLX 語音環境還沒安裝完成,請先按「一鍵配置環境」')
230
+ return 'mlx'
231
+ }
232
+ if (wanted === 'gguf') {
233
+ if (!detected.gguf.ok) throw new Error('CosyVoice GGUF 引擎還沒安裝完成,請先按「一鍵配置環境」')
234
+ return 'gguf'
235
+ }
236
+ if (detected.mlx.ok && (detected.platform === 'darwin' || detected.preferred === 'mlx')) return 'mlx'
237
+ if (detected.gguf.ok) return 'gguf'
238
+ if (detected.mlx.ok) return 'mlx'
239
+ throw new Error('還沒有可用的語音引擎,請先按「一鍵配置環境」')
240
+ }
241
+
242
+ async function run(command, args, { env = process.env, timeout = 180000, maxBuffer = 20 * 1024 * 1024 } = {}) {
243
+ return execFileAsync(command, args, { env, timeout, maxBuffer })
244
+ }
245
+
246
+ function findPython() {
247
+ const candidates = [
248
+ process.env.LINEBOT_MLX_PYTHON,
249
+ '/opt/homebrew/opt/python@3.12/bin/python3.12',
250
+ '/opt/homebrew/bin/python3.12',
251
+ '/usr/local/bin/python3.12',
252
+ '/opt/homebrew/bin/python3.11',
253
+ '/usr/local/bin/python3.11',
254
+ ].filter(Boolean)
255
+ for (const candidate of candidates) {
256
+ if (!fileExists(candidate)) continue
257
+ const version = pythonVersion(candidate)
258
+ if (version.major > 3 || (version.major === 3 && version.minor >= 10)) return candidate
259
+ }
260
+ const system = which('python3')
261
+ if (system && !system.includes('DeepSeek Harness.app')) {
262
+ const version = pythonVersion(system)
263
+ if (version.major > 3 || (version.major === 3 && version.minor >= 10)) return system
264
+ }
265
+ return ''
266
+ }
267
+
268
+ function uvCommand() {
269
+ const candidates = [path.join(os.homedir(), '.local', 'bin', 'uv'), '/opt/homebrew/bin/uv', which('uv')].filter(Boolean)
270
+ return candidates.find((item) => fileExists(item)) || ''
271
+ }
272
+
273
+ async function installMlx() {
274
+ setSetup('python', '檢查 Python(MLX 需要 3.10 以上)')
275
+ let python = findPython()
276
+ if (!python) {
277
+ const brew = which('brew')
278
+ if (!brew) throw new Error('找不到 Python 3.10 以上,也找不到 Homebrew。請先安裝 Python 3.12。')
279
+ setSetup('python', '用 Homebrew 安裝 Python 3.12(第一次會比較久)')
280
+ await run(brew, ['install', 'python@3.12'], { timeout: 1200000 })
281
+ python = findPython()
282
+ }
283
+ if (!python) throw new Error('找不到可用的 Python 3.10 以上')
284
+
285
+ if (!fileExists(paths.mlxPython)) {
286
+ setSetup('venv', '建立 MLX 專用 Python 環境')
287
+ await run(python, ['-m', 'venv', paths.mlxVenv], { timeout: 120000 })
288
+ } else {
289
+ setSetup('venv', 'MLX Python 環境已存在')
290
+ }
291
+
292
+ const uv = uvCommand()
293
+ const pipBase = uv ? [uv, 'pip', 'install', '--python', paths.mlxPython] : [paths.mlxPython, '-m', 'pip', 'install']
294
+ setSetup('pip', '安裝 MLX 語音套件(mlx-audio-plus)')
295
+ const packages = ['mlx-audio-plus==0.1.7', 'mlx-lm==0.29.1', 'librosa', 'pyloudnorm', 'soundfile']
296
+ await run(pipBase[0], [...pipBase.slice(1), ...packages], { timeout: 1800000, env: { ...process.env, HF_HOME: paths.hfHome } })
297
+
298
+ if (!mlxModelCached()) {
299
+ setSetup('model', '下載 CosyVoice3-0.5B 模型(約 1.4GB,第一次會比較久)')
300
+ const downloader = [
301
+ 'from huggingface_hub import snapshot_download',
302
+ `snapshot_download(${JSON.stringify(MLX_MODEL)}, cache_dir=${JSON.stringify(path.join(paths.hfHome, 'hub'))})`,
303
+ ].join('\n')
304
+ await run(paths.mlxPython, ['-c', downloader], {
305
+ timeout: 3600000,
306
+ env: { ...process.env, HF_HOME: paths.hfHome },
307
+ })
308
+ } else {
309
+ setSetup('model', 'MLX 模型已下載')
310
+ }
311
+ if (!mlxReady()) throw new Error('MLX 套件安裝後仍無法載入,請查看日誌')
312
+ return 'mlx'
313
+ }
314
+
315
+ async function downloadFile(url, target) {
316
+ const res = await fetch(url, { redirect: 'follow' })
317
+ if (!res.ok) throw new Error(`下載失敗(HTTP ${res.status}):${url}`)
318
+ const bytes = Buffer.from(await res.arrayBuffer())
319
+ fs.writeFileSync(target, bytes)
320
+ }
321
+
322
+ async function installMacGguf() {
323
+ setSetup('engine', '下載 CosyVoice GGUF 引擎(macOS 備援)')
324
+ const target = path.join(root, 'crispasr')
325
+ fs.mkdirSync(target, { recursive: true })
326
+ const tgz = path.join(root, 'crispasr-macos-arm64.tar.gz')
327
+ await downloadFile(CRISPASR_MAC_URL, tgz)
328
+ await run('tar', ['-xzf', tgz, '-C', target], { timeout: 180000 })
329
+ if (!fileExists(paths.macCrisp)) throw new Error('解壓縮後找不到 crispasr 執行檔')
330
+ fs.chmodSync(paths.macCrisp, 0o755)
331
+ return 'gguf'
332
+ }
333
+
334
+ async function installWinGguf() {
335
+ setSetup('engine', '下載 CosyVoice GGUF 引擎(Windows)')
336
+ fs.mkdirSync(paths.winDir, { recursive: true })
337
+ const zip = path.join(root, 'crispasr-windows.zip')
338
+ await downloadFile(CRISPASR_WIN_URL, zip)
339
+ await run('powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath ${JSON.stringify(zip)} -DestinationPath ${JSON.stringify(paths.winDir)} -Force`], { timeout: 300000 })
340
+ const found = []
341
+ for (const name of fs.readdirSync(paths.winDir)) {
342
+ const full = path.join(paths.winDir, name)
343
+ if (/crispasr(\.exe)?$/i.test(name)) found.push(full)
344
+ if (dirExists(full)) {
345
+ for (const sub of fs.readdirSync(full)) {
346
+ if (/^crispasr(\.exe)?$/i.test(sub)) found.push(path.join(full, sub))
347
+ }
348
+ }
349
+ }
350
+ if (!found[0]) throw new Error('解壓縮後找不到 crispasr.exe')
351
+ if (found[0] !== paths.winCrisp) fs.copyFileSync(found[0], paths.winCrisp)
352
+ return 'gguf'
353
+ }
354
+
355
+ async function ensureDefaultReference() {
356
+ setSetup('reference', '準備預設參考音')
357
+ if (fileExists(paths.defaultRef)) return paths.defaultRef
358
+ const cfg = cfgVoice()
359
+ let source = String(cfg.referenceSource || '').trim()
360
+ if (!source || !fileExists(source)) {
361
+ const downloads = path.join(os.homedir(), 'Downloads')
362
+ try {
363
+ const hit = fs.readdirSync(downloads).find((name) => name.includes('本间心铃'))
364
+ if (hit) source = path.join(downloads, hit)
365
+ } catch {
366
+ /* no Downloads */
367
+ }
368
+ }
369
+ if (!source || !fileExists(source)) {
370
+ if (fileExists(paths.defaultRef)) return paths.defaultRef
371
+ throw new Error('找不到預設參考音,請在面板上傳一段聲音檔')
372
+ }
373
+ const ffmpeg = which('ffmpeg')
374
+ if (!ffmpeg) throw new Error('需要 ffmpeg 才能處理參考音,請先安裝 ffmpeg')
375
+ await run(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', '-ss', '12', '-t', '4.2', '-i', source, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', paths.defaultRef], { timeout: 120000 })
376
+ return paths.defaultRef
377
+ }
378
+
379
+ async function install() {
380
+ if (state.installing) return { started: false, already: true }
381
+ state.installing = true
382
+ state.setup.startedAt = Date.now()
383
+ setSetup('detect', '偵測系統環境')
384
+ try {
385
+ const detected = detect()
386
+ let provider = ''
387
+ if (detected.platform === 'darwin' && detected.arch === 'arm64') {
388
+ try {
389
+ provider = await installMlx()
390
+ } catch (error) {
391
+ setSetup('fallback', 'MLX 安裝失敗,改用 GGUF 備援', error)
392
+ provider = await installMacGguf()
393
+ }
394
+ } else if (detected.platform === 'win32') {
395
+ provider = await installWinGguf()
396
+ } else {
397
+ provider = await installMacGguf()
398
+ }
399
+ await ensureDefaultReference()
400
+ const env = { ...process.env, HF_HOME: paths.hfHome }
401
+ setSetup('test', '測試語音合成')
402
+ await synthesize('主人,語音功能測試成功。', { forceProvider: provider, env, skipConsent: true })
403
+ setSetup('done', `環境已就緒(${provider === 'mlx' ? 'macOS MLX' : 'CosyVoice GGUF'})`)
404
+ return { started: true, provider }
405
+ } catch (error) {
406
+ setSetup('error', '環境安裝失敗', error)
407
+ throw error
408
+ } finally {
409
+ state.installing = false
410
+ }
411
+ }
412
+
413
+ function cleanText(text, maxChars) {
414
+ const value = String(text || '')
415
+ .replace(/\[\[\s*(?:voice|語音|语音)\s*\]\]/gi, '')
416
+ .replace(/[*_`#>]/g, '')
417
+ .replace(/\s+/g, ' ')
418
+ .trim()
419
+ return value.slice(0, Math.max(1, maxChars))
420
+ }
421
+
422
+ async function synthesize(text, { forceProvider = '', env = process.env, skipConsent = false, instructText: instructOverride = '' } = {}) {
423
+ const cfg = cfgVoice()
424
+ if (!skipConsent && cfg.consent !== true) throw new Error('尚未確認聲音使用權利(面板:語音回覆 → 我確認有使用權)')
425
+ if (!skipConsent && cfg.enabled === false) throw new Error('語音功能未開啟')
426
+ const provider = forceProvider || resolveProvider(cfg)
427
+ const ref = referencePath()
428
+ if (!ref) throw new Error('尚未設定參考音')
429
+ const maxChars = Math.max(20, Math.min(300, Number(cfg.maxChars) || 80))
430
+ const cleaned = cleanText(text, maxChars)
431
+ if (!cleaned) throw new Error('沒有可合成的文字')
432
+ const ffmpeg = which('ffmpeg')
433
+ if (!ffmpeg) throw new Error('找不到 ffmpeg')
434
+ const id = randomUUID().slice(0, 8)
435
+ const prefix = path.join(paths.outDir, `tts-${id}`)
436
+ const startedAt = Date.now()
437
+ const instructText = String(instructOverride || cfg.instructText || DEFAULT_INSTRUCT).trim()
438
+ const cloneMode = String(cfg.cloneMode || 'instruct')
439
+ let wav = ''
440
+ try {
441
+ if (provider === 'mlx') {
442
+ const jobFile = path.join(paths.jobsDir, `${id}.json`)
443
+ const payload = {
444
+ text: cleaned,
445
+ model: String(cfg.model || MLX_MODEL),
446
+ refAudio: ref,
447
+ prefix,
448
+ instructText: cloneMode === 'instruct' ? instructText : '',
449
+ refText: cloneMode === 'zero-shot' ? String(cfg.refText || '') : '',
450
+ }
451
+ fs.writeFileSync(jobFile, JSON.stringify(payload), { mode: 0o600 })
452
+ const { stdout } = await run(paths.mlxPython, [paths.mlxScript, jobFile], {
453
+ timeout: 600000,
454
+ env: { ...env, HF_HOME: paths.hfHome },
455
+ })
456
+ const line = String(stdout || '').split(/\r?\n/).reverse().find((item) => item.startsWith('RESULT:'))
457
+ if (!line) throw new Error(`MLX 沒有回傳結果:${String(stdout).slice(-300)}`)
458
+ const result = JSON.parse(line.slice('RESULT:'.length))
459
+ if (!result.ok || !result.file) throw new Error('MLX 沒有產生音檔')
460
+ wav = result.file
461
+ } else {
462
+ const bin = ggufBinary()
463
+ if (!bin) throw new Error('找不到 crispasr 執行檔')
464
+ wav = `${prefix}.wav`
465
+ const args = ['--backend', GGUF_BACKEND, '-m', String(cfg.model || 'auto'), '--voice', ref, '--tts', cleaned, '--tts-output', wav, '-tl', 'zh', '--i-have-rights']
466
+ if (cloneMode === 'instruct' && instructText) args.push('--instruct', instructText)
467
+ if (cloneMode === 'zero-shot' && cfg.refText) args.push('--ref-text', String(cfg.refText))
468
+ if (cfg.skipDisclaimer !== false) args.push('--no-spoken-disclaimer', '--accept-marking-responsibility')
469
+ const { stderr } = await run(bin, args, { timeout: 900000, env: { ...env, HF_HOME: paths.hfHome } })
470
+ if (!fileExists(wav)) throw new Error(`GGUF 沒有產生音檔:${String(stderr || '').slice(-300)}`)
471
+ }
472
+ const m4a = `${prefix}.m4a`
473
+ await run(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', '-i', wav, '-c:a', 'aac', '-b:a', '96k', '-ar', '44100', '-ac', '1', m4a], { timeout: 120000 })
474
+ const durationMs = await fileDurationMs(m4a)
475
+ const bytes = fs.statSync(m4a).size
476
+ state.last = { provider, at: Date.now(), durationMs, bytes, chars: cleaned.length, processingMs: Date.now() - startedAt }
477
+ state.lastError = ''
478
+ return { provider, wav, m4a, durationMs, bytes, chars: cleaned.length, processingMs: state.last.processingMs }
479
+ } catch (error) {
480
+ state.lastError = String(error?.message || error)
481
+ throw error
482
+ }
483
+ }
484
+
485
+ async function fileDurationMs(file) {
486
+ try {
487
+ const { stdout } = await run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file], { timeout: 15000 })
488
+ return Math.max(0, Math.round(Number(String(stdout).trim()) * 1000))
489
+ } catch {
490
+ return 0
491
+ }
492
+ }
493
+
494
+ async function saveReferenceBuffer(buffer, filename, ext = '.wav') {
495
+ const safe = String(filename || 'upload').replace(/[^\w.\-()\u4e00-\u9fff ]+/g, '_').slice(0, 60) || 'upload'
496
+ const src = path.join(paths.refDir, `${Date.now()}-${safe}${safe.endsWith(ext) ? '' : ext}`)
497
+ fs.writeFileSync(src, buffer)
498
+ if (!fileExists(src)) throw new Error('寫入上傳檔案失敗')
499
+ const ffmpeg = which('ffmpeg')
500
+ if (!ffmpeg) throw new Error('需要 ffmpeg 才能轉換音檔')
501
+ const dest = path.join(paths.refDir, `upload-${Date.now()}.wav`)
502
+ await run(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', '-i', src, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', dest], { timeout: 120000 })
503
+ const info = referenceInfoFor(dest)
504
+ return { path: dest, ...info }
505
+ }
506
+
507
+ function referenceInfoFor(file) {
508
+ let seconds = 0
509
+ try {
510
+ const out = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file], { encoding: 'utf8', timeout: 8000 })
511
+ seconds = Number(out.trim()) || 0
512
+ } catch {
513
+ seconds = 0
514
+ }
515
+ return { seconds: Math.round(seconds * 100) / 100 }
516
+ }
517
+
518
+ function status() {
519
+ const detected = detect()
520
+ return {
521
+ ...detected,
522
+ reference: detected.reference,
523
+ setup: state.setup,
524
+ installing: state.installing,
525
+ last: state.last,
526
+ lastError: state.lastError,
527
+ mlxScript: fileExists(paths.mlxScript),
528
+ lastSample: (() => {
529
+ try {
530
+ const files = fs.readdirSync(paths.outDir).filter((name) => name.endsWith('.m4a')).sort().reverse()
531
+ return files[0] ? path.join(paths.outDir, files[0]) : ''
532
+ } catch {
533
+ return ''
534
+ }
535
+ })(),
536
+ }
537
+ }
538
+
539
+ return { detect, install, synthesize, status, saveReferenceBuffer, referencePath, paths }
540
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.9.6",
3
+ "version": "0.10.0",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -17,6 +17,7 @@
17
17
  "client.js",
18
18
  "cordis.patch.yml",
19
19
  "lib",
20
+ "tools",
20
21
  "locale",
21
22
  "icon.svg",
22
23
  "README.md",
@@ -65,7 +66,7 @@
65
66
  "url": "https://github.com/Hanson0901/dsh-line-bot/issues"
66
67
  },
67
68
  "bin": {
68
- "dsh-line-bot": "./bin/cli.mjs"
69
+ "dsh-line-bot": "bin/cli.mjs"
69
70
  },
70
71
  "publishConfig": {
71
72
  "access": "public"