dsh-line-bot 0.10.1 → 0.10.2
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/CHANGELOG.md +6 -0
- package/TROUBLESHOOTING.md +5 -0
- package/lib/host.mjs +2 -2
- package/lib/tts.mjs +93 -22
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 0.10.2
|
|
2
|
+
|
|
3
|
+
- 修正 Windows 一鍵配置語音環境:改為直接使用解壓後的 `crispasr.exe`(與 `openblas.dll` 同資料夾),不再只複製 exe 導致缺少 DLL、`--version` 失敗。
|
|
4
|
+
- 安裝時會實際執行 `crispasr.exe --version` 驗證,失敗時回報缺少 DLL 的細節。
|
|
5
|
+
- 修正面板卡在「讀取中」:語音環境偵測改為快取+只用檔案檢查,`/api/line-bot/status` 不再因 MLX 匯入檢查被阻塞(先前每次可能卡 30 秒以上)。
|
|
6
|
+
|
|
1
7
|
## 0.10.1
|
|
2
8
|
|
|
3
9
|
- 語音:自然語言語氣(instruct)改用 CosyVoice GGUF;MLX 的 instruct 模式會把語氣指令一起念出來,已避免。
|
package/TROUBLESHOOTING.md
CHANGED
|
@@ -38,6 +38,11 @@ npx dsh-line-bot doctor --fix # 安全修復:重連 tunnel、重設 webhook
|
|
|
38
38
|
- 合成約需 20–40 秒;reply token 過期時會自動改用 push。日誌看 `語音回覆`、`語音合成失敗`。
|
|
39
39
|
- 模型只有在語言上下文有語音提示時才會加 `[[voice]]`;可先用「測試 Agent 回覆」確認。
|
|
40
40
|
|
|
41
|
+
### Windows 一鍵配置語音失敗
|
|
42
|
+
- 0.10.2 起會直接使用解壓後的 `crispasr.exe`,它必須和 `openblas.dll` 在同一個資料夾。
|
|
43
|
+
- 若仍失敗,到 `%USERPROFILE%\.dsh\line-bot\tts\crispasr\windows\` 找 `crispasr-windows-x86_64-cpu\crispasr.exe`,手動雙擊或執行 `crispasr.exe --version` 看缺哪個 DLL。
|
|
44
|
+
- 也需要 `ffmpeg` 在 PATH 中。
|
|
45
|
+
|
|
41
46
|
### 語音把語氣指令念出來
|
|
42
47
|
- 舊版 MLX 的 instruct 模式會把語氣描述一起念;0.10.1 起 instruct 一律走 CosyVoice GGUF。升級後重新產生一次語音即可,舊音檔不會變。
|
|
43
48
|
|
package/lib/host.mjs
CHANGED
|
@@ -51,7 +51,7 @@ const { createVision } = visionMod
|
|
|
51
51
|
const { createTts } = ttsMod
|
|
52
52
|
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep, readJsonSafe } = utilMod
|
|
53
53
|
|
|
54
|
-
export const VERSION = '0.10.
|
|
54
|
+
export const VERSION = '0.10.2'
|
|
55
55
|
|
|
56
56
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
57
57
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -2801,7 +2801,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2801
2801
|
return { messages: built.messages, parsed: built.parsed }
|
|
2802
2802
|
}
|
|
2803
2803
|
case 'voice-detect':
|
|
2804
|
-
return tts.detect()
|
|
2804
|
+
return tts.detect({ force: true, validate: true })
|
|
2805
2805
|
case 'voice-install': {
|
|
2806
2806
|
if (tts.status().installing) return { started: false, already: true }
|
|
2807
2807
|
tts.install()
|
package/lib/tts.mjs
CHANGED
|
@@ -94,8 +94,13 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
94
94
|
last: null,
|
|
95
95
|
lastError: '',
|
|
96
96
|
installing: false,
|
|
97
|
+
ggufBinary: '',
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
let detectCache = { at: 0, value: null }
|
|
101
|
+
let refCache = { at: 0, key: '', value: null }
|
|
102
|
+
let mlxError = ''
|
|
103
|
+
|
|
99
104
|
function setSetup(phase, message, error = '') {
|
|
100
105
|
state.setup = {
|
|
101
106
|
phase,
|
|
@@ -126,6 +131,15 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
126
131
|
function referenceInfo() {
|
|
127
132
|
const file = referencePath()
|
|
128
133
|
if (!file) return { path: '', exists: false, seconds: 0 }
|
|
134
|
+
let stamp = ''
|
|
135
|
+
try {
|
|
136
|
+
const st = fs.statSync(file)
|
|
137
|
+
stamp = `${st.size}:${st.mtimeMs}`
|
|
138
|
+
} catch {
|
|
139
|
+
stamp = ''
|
|
140
|
+
}
|
|
141
|
+
const key = `${file}|${stamp}`
|
|
142
|
+
if (refCache.value && refCache.key === key && Date.now() - refCache.at < 60000) return refCache.value
|
|
129
143
|
let seconds = 0
|
|
130
144
|
try {
|
|
131
145
|
const out = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file], {
|
|
@@ -136,7 +150,9 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
136
150
|
} catch {
|
|
137
151
|
seconds = 0
|
|
138
152
|
}
|
|
139
|
-
|
|
153
|
+
const value = { path: file, exists: true, seconds: Math.round(seconds * 100) / 100 }
|
|
154
|
+
refCache = { at: Date.now(), key, value }
|
|
155
|
+
return value
|
|
140
156
|
}
|
|
141
157
|
|
|
142
158
|
function mlxModelCached() {
|
|
@@ -156,8 +172,20 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
156
172
|
return false
|
|
157
173
|
}
|
|
158
174
|
|
|
159
|
-
|
|
160
|
-
|
|
175
|
+
function sitePackages(name) {
|
|
176
|
+
try {
|
|
177
|
+
const lib = path.join(paths.mlxVenv, 'lib')
|
|
178
|
+
for (const dir of fs.readdirSync(lib)) {
|
|
179
|
+
if (!dir.startsWith('python')) continue
|
|
180
|
+
if (dirExists(path.join(lib, dir, 'site-packages', name))) return true
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
/* ignore */
|
|
184
|
+
}
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function mlxReady({ validate = false } = {}) {
|
|
161
189
|
if (!fileExists(paths.mlxPython)) {
|
|
162
190
|
mlxError = '找不到 MLX Python 環境'
|
|
163
191
|
return false
|
|
@@ -166,8 +194,16 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
166
194
|
mlxError = `找不到 MLX 合成腳本:${paths.mlxScript}`
|
|
167
195
|
return false
|
|
168
196
|
}
|
|
197
|
+
if (!sitePackages('mlx_audio') || !sitePackages('mlx_lm')) {
|
|
198
|
+
mlxError = 'MLX 套件尚未安裝完成'
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
if (!validate) {
|
|
202
|
+
mlxError = ''
|
|
203
|
+
return true
|
|
204
|
+
}
|
|
169
205
|
try {
|
|
170
|
-
execFileSync(paths.mlxPython, ['-c', 'import mlx_audio, mlx_lm, librosa'], { stdio: 'ignore', timeout:
|
|
206
|
+
execFileSync(paths.mlxPython, ['-c', 'import mlx_audio, mlx_lm, librosa'], { stdio: 'ignore', timeout: 20000 })
|
|
171
207
|
mlxError = ''
|
|
172
208
|
return true
|
|
173
209
|
} catch (error) {
|
|
@@ -176,14 +212,39 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
176
212
|
}
|
|
177
213
|
}
|
|
178
214
|
|
|
215
|
+
function findCrispExe(dir, depth = 3) {
|
|
216
|
+
if (depth < 0) return ''
|
|
217
|
+
let entries = []
|
|
218
|
+
try {
|
|
219
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
220
|
+
} catch {
|
|
221
|
+
return ''
|
|
222
|
+
}
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
if (entry.isFile() && /^crispasr\.exe$/i.test(entry.name)) return path.join(dir, entry.name)
|
|
225
|
+
}
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
if (!entry.isDirectory()) continue
|
|
228
|
+
const hit = findCrispExe(path.join(dir, entry.name), depth - 1)
|
|
229
|
+
if (hit) return hit
|
|
230
|
+
}
|
|
231
|
+
return ''
|
|
232
|
+
}
|
|
233
|
+
|
|
179
234
|
function ggufBinary() {
|
|
180
|
-
|
|
235
|
+
if (state.ggufBinary && fileExists(state.ggufBinary)) return state.ggufBinary
|
|
236
|
+
if (isWin()) {
|
|
237
|
+
const found = findCrispExe(paths.winDir)
|
|
238
|
+
if (found) return found
|
|
239
|
+
}
|
|
240
|
+
const candidates = [isWin() ? '' : paths.macCrisp, which('crispasr')]
|
|
181
241
|
return candidates.find((item) => item && fileExists(item)) || ''
|
|
182
242
|
}
|
|
183
243
|
|
|
184
|
-
function ggufReady() {
|
|
244
|
+
function ggufReady({ validate = false } = {}) {
|
|
185
245
|
const bin = ggufBinary()
|
|
186
246
|
if (!bin) return false
|
|
247
|
+
if (!validate) return true
|
|
187
248
|
try {
|
|
188
249
|
execFileSync(bin, ['--version'], { stdio: 'ignore', timeout: 8000 })
|
|
189
250
|
return true
|
|
@@ -192,19 +253,23 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
192
253
|
}
|
|
193
254
|
}
|
|
194
255
|
|
|
195
|
-
function detect() {
|
|
256
|
+
function detect({ force = false, validate = false } = {}) {
|
|
257
|
+
const now = Date.now()
|
|
258
|
+
if (!force && !validate && detectCache.value && now - detectCache.at < 120000) return detectCache.value
|
|
196
259
|
const platform = process.platform
|
|
197
260
|
const arch = process.arch
|
|
198
261
|
const preferred = platform === 'darwin' && arch === 'arm64' ? 'mlx' : 'gguf'
|
|
199
262
|
const ffmpeg = which('ffmpeg')
|
|
200
263
|
const ref = referenceInfo()
|
|
201
|
-
|
|
264
|
+
const mlxOk = mlxReady({ validate })
|
|
265
|
+
const ggufOk = ggufReady({ validate })
|
|
266
|
+
const value = {
|
|
202
267
|
platform,
|
|
203
268
|
arch,
|
|
204
269
|
preferred,
|
|
205
270
|
ffmpeg: { path: ffmpeg, ok: !!ffmpeg },
|
|
206
271
|
mlx: {
|
|
207
|
-
ok:
|
|
272
|
+
ok: mlxOk,
|
|
208
273
|
python: fileExists(paths.mlxPython) ? paths.mlxPython : '',
|
|
209
274
|
model: MLX_MODEL,
|
|
210
275
|
modelCached: mlxModelCached(),
|
|
@@ -212,14 +277,16 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
212
277
|
script: paths.mlxScript,
|
|
213
278
|
},
|
|
214
279
|
gguf: {
|
|
215
|
-
ok:
|
|
280
|
+
ok: ggufOk,
|
|
216
281
|
binary: ggufBinary(),
|
|
217
282
|
backend: GGUF_BACKEND,
|
|
218
283
|
},
|
|
219
284
|
reference: ref,
|
|
220
285
|
setup: state.setup,
|
|
221
|
-
ready: (
|
|
286
|
+
ready: (mlxOk || ggufOk) && !!ref.path,
|
|
222
287
|
}
|
|
288
|
+
detectCache = { at: now, value }
|
|
289
|
+
return value
|
|
223
290
|
}
|
|
224
291
|
|
|
225
292
|
function providerFor(cfg = cfgVoice()) {
|
|
@@ -349,18 +416,15 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
349
416
|
const zip = path.join(root, 'crispasr-windows.zip')
|
|
350
417
|
await downloadFile(CRISPASR_WIN_URL, zip)
|
|
351
418
|
await run('powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath ${JSON.stringify(zip)} -DestinationPath ${JSON.stringify(paths.winDir)} -Force`], { timeout: 300000 })
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
}
|
|
419
|
+
const exe = findCrispExe(paths.winDir)
|
|
420
|
+
if (!exe) throw new Error('解壓縮後找不到 crispasr.exe')
|
|
421
|
+
try {
|
|
422
|
+
execFileSync(exe, ['--version'], { encoding: 'utf8', timeout: 15000 })
|
|
423
|
+
} catch (error) {
|
|
424
|
+
const detail = String(error?.stderr || error?.message || error).slice(0, 300)
|
|
425
|
+
throw new Error(`crispasr.exe 無法執行(可能是缺少 DLL):${detail}`)
|
|
361
426
|
}
|
|
362
|
-
|
|
363
|
-
if (found[0] !== paths.winCrisp) fs.copyFileSync(found[0], paths.winCrisp)
|
|
427
|
+
state.ggufBinary = exe
|
|
364
428
|
return 'gguf'
|
|
365
429
|
}
|
|
366
430
|
|
|
@@ -420,6 +484,13 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
420
484
|
setSetup('test', '測試語音合成(instruct 模式會用 GGUF 引擎)')
|
|
421
485
|
await synthesize('主人,語音功能測試成功。', { env, skipConsent: true })
|
|
422
486
|
const finalProvider = providerFor() || provider
|
|
487
|
+
try {
|
|
488
|
+
fs.writeFileSync(path.join(root, '.ready'), new Date().toISOString())
|
|
489
|
+
} catch {
|
|
490
|
+
/* ignore */
|
|
491
|
+
}
|
|
492
|
+
detectCache = { at: 0, value: null }
|
|
493
|
+
refCache = { at: 0, key: '', value: null }
|
|
423
494
|
setSetup('done', `環境已就緒(${finalProvider === 'mlx' ? 'macOS MLX' : 'CosyVoice GGUF'})`)
|
|
424
495
|
return { started: true, provider: finalProvider }
|
|
425
496
|
} catch (error) {
|