dsh-line-bot 0.10.0 → 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 +12 -0
- package/README.en.md +1 -0
- package/README.ja.md +3 -0
- package/README.ko.md +3 -0
- package/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/TROUBLESHOOTING.md +8 -0
- package/client.js +2 -2
- package/lib/host.mjs +2 -2
- package/lib/tts.mjs +126 -29
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
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
|
+
|
|
7
|
+
## 0.10.1
|
|
8
|
+
|
|
9
|
+
- 語音:自然語言語氣(instruct)改用 CosyVoice GGUF;MLX 的 instruct 模式會把語氣指令一起念出來,已避免。
|
|
10
|
+
- 語音:MLX 合成加上 `NUMBA_CACHE_DIR`,避免新版 Python 環境第一次執行就失敗。
|
|
11
|
+
- 面板與文件更新語音引擎說明。
|
|
12
|
+
|
|
1
13
|
## 0.10.0
|
|
2
14
|
|
|
3
15
|
- 新增語音回覆(CosyVoice3-0.5B):角色可以在回覆最後加 `[[voice]]`,插件會把文字合成語音並用 LINE 語音訊息送出;合成失敗或 reply token 過期時自動改用文字/push。
|
package/README.en.md
CHANGED
|
@@ -230,6 +230,7 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
|
|
|
230
230
|
- The persona can put `[[voice]]` at the end of a reply; the plugin synthesizes the text with CosyVoice3-0.5B and sends a LINE audio message (m4a).
|
|
231
231
|
- On Apple Silicon macOS it synthesizes locally with MLX; on Windows (or without MLX) it uses CosyVoice GGUF (CrispASR). Press "Set up environment" once in the panel (downloads roughly 1–2 GB).
|
|
232
232
|
- The "Voice replies" card toggles the feature, requires confirming that you have the rights to the voice, lets you upload a reference clip (the default is a 12–16 s cut of the provided sample), choose instruct/zero-shot mode, and preview the audio.
|
|
233
|
+
- Natural-language style (instruct) uses CosyVoice GGUF; MLX is only used for zero-shot, so the instruction is never read aloud.
|
|
233
234
|
- Synthesis takes about 20–40 s; if the reply token expires it falls back to push. Audio is stored under `<DSH_HOME>/line-bot/media/` and served at `/media/<token>` through the tunnel.
|
|
234
235
|
- Requires `ffmpeg`; MLX mode needs Python 3.10+.
|
|
235
236
|
|
package/README.ja.md
CHANGED
|
@@ -233,6 +233,9 @@ Bot がグループ(または複数人のルーム)に追加されたとき
|
|
|
233
233
|
- 合成には約 20–40 秒かかります。reply token の期限が切れた場合は push に切り替わります。音声は `<DSH_HOME>/line-bot/media/` に保存され、tunnel の `/media/<token>` で配信されます。
|
|
234
234
|
- `ffmpeg` が必要です。MLX モードは Python 3.10+ が必要です。
|
|
235
235
|
|
|
236
|
+
- 自然言語の話し方(instruct)は CosyVoice GGUF を使用します。MLX はゼロショット専用で、指示が読み上げられるのを防ぎます。
|
|
237
|
+
|
|
238
|
+
|
|
236
239
|
## 診断
|
|
237
240
|
- パネルの「詳細設定 → 診断を実行」でサーバー、Tunnel、公開 URL、webhook、dsh-meme 図庫、画像変換ツール、モデル、最近のエラーを確認できます。「安全な修復」は tunnel 再接続と webhook 再設定だけを行います。
|
|
238
241
|
- CLI:`npx dsh-line-bot doctor`、`doctor --fix`、`doctor --json`。
|
package/README.ko.md
CHANGED
|
@@ -231,6 +231,9 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
|
|
|
231
231
|
- 합성에는 약 20–40초가 걸립니다. reply token이 만료되면 push로 자동 전환됩니다. 음성은 `<DSH_HOME>/line-bot/media/`에 저장되고 tunnel의 `/media/<token>`으로 제공됩니다.
|
|
232
232
|
- `ffmpeg`가 필요합니다. MLX 모드는 Python 3.10+가 필요합니다.
|
|
233
233
|
|
|
234
|
+
- 자연어 말투(instruct)는 CosyVoice GGUF를 사용합니다. MLX는 제로샷 전용이라 지시문이 읽히지 않습니다.
|
|
235
|
+
|
|
236
|
+
|
|
234
237
|
## 진단
|
|
235
238
|
- 패널의 "고급 설정 → 진단 실행"에서 서버, Tunnel, 공개 URL, webhook, dsh-meme 라이브러리, 이미지 변환 도구, 모델, 최근 오류를 확인할 수 있습니다. "안전 복구"는 tunnel 재연결과 webhook 재설정만 수행합니다.
|
|
236
239
|
- CLI: `npx dsh-line-bot doctor`, `doctor --fix`, `doctor --json`.
|
package/README.md
CHANGED
|
@@ -230,6 +230,7 @@ Bot 被加進群組(或多人聊天室)後:
|
|
|
230
230
|
- 角色可以在回覆最後加 `[[voice]]`,插件會用 CosyVoice3-0.5B 把文字合成語音,並以 LINE 語音訊息送出(m4a)。
|
|
231
231
|
- macOS(Apple Silicon)在本機用 MLX 合成;Windows/沒有 MLX 時用 CosyVoice GGUF(CrispASR)。第一次要先在面板按「一鍵配置環境」(會下載模型,約 1–2GB)。
|
|
232
232
|
- 面板「語音回覆」可開關、確認聲音使用權、上傳參考音(預設用指定音檔的 12–16 秒片段)、選推理模式與語氣指令、試聽。
|
|
233
|
+
- 自然語言語氣(instruct)使用 CosyVoice GGUF;MLX 只用於零樣本模式,避免語氣指令被念出來。
|
|
233
234
|
- 合成需要約 20–40 秒;若 reply token 過期會自動改用 push。語音檔存在 `<DSH_HOME>/line-bot/media/`,透過 tunnel 的 `/media/<token>` 提供。
|
|
234
235
|
- 需要 `ffmpeg`;MLX 模式需要 Python 3.10+。
|
|
235
236
|
|
package/README.zh-CN.md
CHANGED
|
@@ -230,6 +230,7 @@ Bot 被加入群组(或多人聊天室)后:
|
|
|
230
230
|
- 角色可以在回复最后加 `[[voice]]`,插件会用 CosyVoice3-0.5B 把文字合成语音,并以 LINE 语音消息发送(m4a)。
|
|
231
231
|
- macOS(Apple Silicon)在本机用 MLX 合成;Windows/没有 MLX 时用 CosyVoice GGUF(CrispASR)。第一次要先在面板按「一键配置环境」(会下载模型,约 1–2GB)。
|
|
232
232
|
- 面板「语音回复」可开关、确认声音使用权、上传参考音频(默认用指定音频的 12–16 秒片段)、选择推理模式与语气指令、试听。
|
|
233
|
+
- 自然语言语气(instruct)使用 CosyVoice GGUF;MLX 只用于零样本模式,避免语气指令被念出来。
|
|
233
234
|
- 合成需要约 20–40 秒;若 reply token 过期会自动改用 push。语音文件存在 `<DSH_HOME>/line-bot/media/`,通过 tunnel 的 `/media/<token>` 提供。
|
|
234
235
|
- 需要 `ffmpeg`;MLX 模式需要 Python 3.10+。
|
|
235
236
|
|
package/TROUBLESHOOTING.md
CHANGED
|
@@ -38,6 +38,14 @@ 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
|
+
|
|
46
|
+
### 語音把語氣指令念出來
|
|
47
|
+
- 舊版 MLX 的 instruct 模式會把語氣描述一起念;0.10.1 起 instruct 一律走 CosyVoice GGUF。升級後重新產生一次語音即可,舊音檔不會變。
|
|
48
|
+
|
|
41
49
|
### APNG 不會動
|
|
42
50
|
- LINE 不播放 APNG,只顯示第一幀。保持 `stickers.apng=false` 即可。
|
|
43
51
|
|
package/client.js
CHANGED
|
@@ -1415,10 +1415,10 @@ window.__ModuleLoader__.load({
|
|
|
1415
1415
|
h('div', { style: S.row },
|
|
1416
1416
|
h(Badge, { ok: voiceStatus.ready === true }, voiceStatus.ready ? '語音環境就緒' : '尚未就緒'),
|
|
1417
1417
|
h(Badge, { ok: voiceCfg.consent === true }, voiceCfg.consent ? '已確認使用權' : '未確認使用權'),
|
|
1418
|
-
h(Badge, { ok: true }, voiceStatus.
|
|
1418
|
+
h(Badge, { ok: true }, voiceStatus.effectiveProvider === 'gguf' ? 'GGUF' : voiceStatus.effectiveProvider === 'mlx' ? 'MLX' : '未安裝引擎'),
|
|
1419
1419
|
h(Badge, { ok: !!voiceStatus.reference?.path }, voiceStatus.reference?.path ? ('參考音 ' + (voiceStatus.reference.seconds || 0) + ' 秒') : '沒有參考音'),
|
|
1420
1420
|
),
|
|
1421
|
-
h('div', { style: S.hint }, '
|
|
1421
|
+
h('div', { style: S.hint }, '自然語言語氣(instruct)一律用 CosyVoice GGUF,避免 MLX 把語氣指令念出來;零樣本模式可用 MLX。合成一次約需 20–40 秒,reply token 過期會自動改用 push。'),
|
|
1422
1422
|
h(Toggle, { checked: voiceCfg.enabled === true, onChange: (value) => update({ voice: { enabled: value } }), label: '開啟語音回覆', hint: '開啟後角色可以自己決定用語音;關閉時完全不合成。' }),
|
|
1423
1423
|
h(Toggle, { checked: voiceCfg.consent === true, onChange: (value) => update({ voice: { consent: value } }), label: '我確認有使用此聲音的權利', hint: '使用他人聲音做克隆前請先取得同意;確認後才會合成。' }),
|
|
1424
1424
|
h('div', { style: S.row },
|
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,19 +277,33 @@ 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
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function providerFor(cfg = cfgVoice()) {
|
|
293
|
+
const detected = detect()
|
|
294
|
+
const instruct = String(cfg.cloneMode || 'instruct') !== 'zero-shot'
|
|
295
|
+
if (instruct) return detected.gguf.ok ? 'gguf' : ''
|
|
296
|
+
return detected.mlx.ok ? 'mlx' : detected.gguf.ok ? 'gguf' : ''
|
|
223
297
|
}
|
|
224
298
|
|
|
225
299
|
function resolveProvider(cfg = cfgVoice()) {
|
|
226
300
|
const detected = detect()
|
|
227
301
|
const wanted = String(cfg.provider || 'auto')
|
|
302
|
+
const instruct = String(cfg.cloneMode || 'instruct') !== 'zero-shot'
|
|
303
|
+
if (instruct) {
|
|
304
|
+
if (detected.gguf.ok) return 'gguf'
|
|
305
|
+
throw new Error('自然語言語氣(instruct)模式需要 GGUF 引擎;MLX 的 instruct 會把指示念出來。請按「一鍵配置環境」。')
|
|
306
|
+
}
|
|
228
307
|
if (wanted === 'mlx') {
|
|
229
308
|
if (!detected.mlx.ok) throw new Error('MLX 語音環境還沒安裝完成,請先按「一鍵配置環境」')
|
|
230
309
|
return 'mlx'
|
|
@@ -293,7 +372,7 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
293
372
|
const pipBase = uv ? [uv, 'pip', 'install', '--python', paths.mlxPython] : [paths.mlxPython, '-m', 'pip', 'install']
|
|
294
373
|
setSetup('pip', '安裝 MLX 語音套件(mlx-audio-plus)')
|
|
295
374
|
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 } })
|
|
375
|
+
await run(pipBase[0], [...pipBase.slice(1), ...packages], { timeout: 1800000, env: { ...process.env, HF_HOME: paths.hfHome, NUMBA_CACHE_DIR: path.join(paths.root, 'numba') } })
|
|
297
376
|
|
|
298
377
|
if (!mlxModelCached()) {
|
|
299
378
|
setSetup('model', '下載 CosyVoice3-0.5B 模型(約 1.4GB,第一次會比較久)')
|
|
@@ -303,7 +382,7 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
303
382
|
].join('\n')
|
|
304
383
|
await run(paths.mlxPython, ['-c', downloader], {
|
|
305
384
|
timeout: 3600000,
|
|
306
|
-
env: { ...process.env, HF_HOME: paths.hfHome },
|
|
385
|
+
env: { ...process.env, HF_HOME: paths.hfHome, NUMBA_CACHE_DIR: path.join(paths.root, 'numba') },
|
|
307
386
|
})
|
|
308
387
|
} else {
|
|
309
388
|
setSetup('model', 'MLX 模型已下載')
|
|
@@ -337,18 +416,15 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
337
416
|
const zip = path.join(root, 'crispasr-windows.zip')
|
|
338
417
|
await downloadFile(CRISPASR_WIN_URL, zip)
|
|
339
418
|
await run('powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath ${JSON.stringify(zip)} -DestinationPath ${JSON.stringify(paths.winDir)} -Force`], { timeout: 300000 })
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
}
|
|
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}`)
|
|
349
426
|
}
|
|
350
|
-
|
|
351
|
-
if (found[0] !== paths.winCrisp) fs.copyFileSync(found[0], paths.winCrisp)
|
|
427
|
+
state.ggufBinary = exe
|
|
352
428
|
return 'gguf'
|
|
353
429
|
}
|
|
354
430
|
|
|
@@ -398,10 +474,25 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
398
474
|
}
|
|
399
475
|
await ensureDefaultReference()
|
|
400
476
|
const env = { ...process.env, HF_HOME: paths.hfHome }
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
477
|
+
if (detected.platform === 'darwin' && provider !== 'gguf') {
|
|
478
|
+
try {
|
|
479
|
+
await installMacGguf()
|
|
480
|
+
} catch (error) {
|
|
481
|
+
logger.warn(`GGUF 備援引擎安裝失敗:${String(error?.message || error).slice(0, 200)}`)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
setSetup('test', '測試語音合成(instruct 模式會用 GGUF 引擎)')
|
|
485
|
+
await synthesize('主人,語音功能測試成功。', { env, skipConsent: true })
|
|
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 }
|
|
494
|
+
setSetup('done', `環境已就緒(${finalProvider === 'mlx' ? 'macOS MLX' : 'CosyVoice GGUF'})`)
|
|
495
|
+
return { started: true, provider: finalProvider }
|
|
405
496
|
} catch (error) {
|
|
406
497
|
setSetup('error', '環境安裝失敗', error)
|
|
407
498
|
throw error
|
|
@@ -423,6 +514,11 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
423
514
|
const cfg = cfgVoice()
|
|
424
515
|
if (!skipConsent && cfg.consent !== true) throw new Error('尚未確認聲音使用權利(面板:語音回覆 → 我確認有使用權)')
|
|
425
516
|
if (!skipConsent && cfg.enabled === false) throw new Error('語音功能未開啟')
|
|
517
|
+
if (String(cfg.cloneMode || 'instruct') !== 'zero-shot' && !ggufReady()) {
|
|
518
|
+
setSetup('engine', '下載 GGUF 引擎(自然語言語氣模式需要)')
|
|
519
|
+
if (isWin()) await installWinGguf()
|
|
520
|
+
else await installMacGguf()
|
|
521
|
+
}
|
|
426
522
|
const provider = forceProvider || resolveProvider(cfg)
|
|
427
523
|
const ref = referencePath()
|
|
428
524
|
if (!ref) throw new Error('尚未設定參考音')
|
|
@@ -451,7 +547,7 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
451
547
|
fs.writeFileSync(jobFile, JSON.stringify(payload), { mode: 0o600 })
|
|
452
548
|
const { stdout } = await run(paths.mlxPython, [paths.mlxScript, jobFile], {
|
|
453
549
|
timeout: 600000,
|
|
454
|
-
env: { ...env, HF_HOME: paths.hfHome },
|
|
550
|
+
env: { ...env, HF_HOME: paths.hfHome, NUMBA_CACHE_DIR: path.join(paths.root, 'numba') },
|
|
455
551
|
})
|
|
456
552
|
const line = String(stdout || '').split(/\r?\n/).reverse().find((item) => item.startsWith('RESULT:'))
|
|
457
553
|
if (!line) throw new Error(`MLX 沒有回傳結果:${String(stdout).slice(-300)}`)
|
|
@@ -524,6 +620,7 @@ export function createTts({ dir, logger, getConfig }) {
|
|
|
524
620
|
installing: state.installing,
|
|
525
621
|
last: state.last,
|
|
526
622
|
lastError: state.lastError,
|
|
623
|
+
effectiveProvider: (() => { try { return providerFor() } catch { return '' } })(),
|
|
527
624
|
mlxScript: fileExists(paths.mlxScript),
|
|
528
625
|
lastSample: (() => {
|
|
529
626
|
try {
|