@pls-compile-owo/fisheye 0.3.0 → 0.4.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/lib/audio.mjs ADDED
@@ -0,0 +1,645 @@
1
+ // 音频转写(可选):ffmpeg 抽 16kHz 单声道 wav → whisper.cpp / whisper-cli / openai-whisper → txt
2
+ //
3
+ // 定位:fisheye 的信息源顺序是「官方字幕 → 画面/字幕带 → 弹幕」,音频转写是第四路补强:
4
+ // 没有官方字幕、字幕又烧在画面里看不清时,听一遍比只看图准。
5
+ // 它是**可选能力**:找不到 whisper / 模型 / ffmpeg 一律返回 null,绝不让整个看片流程失败。
6
+ //
7
+ // 设计约束(和项目其他模块一致):
8
+ // · 零 npm 依赖,Node ≥18
9
+ // · 所有子进程用 spawnSync;ffmpeg / whisper 主流程 stdio:'ignore'(沙箱禁管道捕获),
10
+ // 需要读输出的探测(where / --help)把 stdout+stderr 重定向到临时文件再读
11
+ // · Windows 路径全部 path.join,可执行文件补 .exe
12
+ import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+ import crypto from 'node:crypto'
16
+ import { spawnSync } from 'node:child_process'
17
+ import { FFMPEG, PKG_DIR } from './env.mjs'
18
+
19
+ const IS_WIN = process.platform === 'win32'
20
+ const EXE = (name) => (IS_WIN ? `${name}.exe` : name)
21
+
22
+ // 探测顺序按需求固定:FISHEYE_WHISPER → whisper-cli → whisper → main → 常见安装路径
23
+ const WHISPER_NAMES = ['whisper-cli', 'whisper', 'main']
24
+
25
+ // 自动发现模型时的偏好:CPU 上 base/small 就够字幕场景,大的留给用户显式指定
26
+ const MODEL_PRIORITY = ['base', 'small', 'medium', 'large-v3', 'large', 'tiny']
27
+
28
+ // --help 探测很便宜但会被 findWhisper / audioCaps / transcribeAudio 反复调用,缓存一次
29
+ const helpCache = new Map()
30
+
31
+ const stripQuotes = (s) => String(s ?? '').trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1')
32
+
33
+ function readText(file) {
34
+ try { return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '') } catch { return '' }
35
+ }
36
+
37
+ function tmpFile(tag) {
38
+ return path.join(os.tmpdir(), `fisheye-${tag}-${process.pid}-${crypto.randomUUID().slice(0, 8)}.txt`)
39
+ }
40
+
41
+ /**
42
+ * 跑一个短命令并把 stdout+stderr 落到临时文件。
43
+ * 沙箱里不能捕获管道,所以不能用 spawnSync 的 'pipe' —— 只能 fd 重定向。
44
+ */
45
+ function capture(cmd, args, timeoutMs = 5000) {
46
+ const file = tmpFile('cap')
47
+ let fd = null
48
+ try {
49
+ fd = fs.openSync(file, 'w')
50
+ const r = spawnSync(cmd, args, {
51
+ stdio: ['ignore', fd, fd],
52
+ timeout: timeoutMs,
53
+ windowsHide: true,
54
+ })
55
+ return {
56
+ ok: r.status === 0,
57
+ status: r.status,
58
+ error: r.error,
59
+ out: readText(file).slice(0, 65536),
60
+ }
61
+ } catch (e) {
62
+ return { ok: false, status: null, error: e, out: '' }
63
+ } finally {
64
+ if (fd !== null) try { fs.closeSync(fd) } catch {}
65
+ try { fs.unlinkSync(file) } catch {}
66
+ }
67
+ }
68
+
69
+ // where.exe / which 的全部命中行(没有命中返回 [])
70
+ function whereAll(name) {
71
+ const r = capture(IS_WIN ? 'where.exe' : 'which', [name], 5000)
72
+ if (r.status !== 0) return []
73
+ return r.out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
74
+ }
75
+
76
+ /**
77
+ * 判断一个 where 命中项是否值得 spawn。
78
+ * Windows 上 `where main` 会命中 C:\Windows\System32\main.cpl —— PATHEXT 里有 .CPL,
79
+ * 但它是控制面板程序,直接 spawn 会 ENOENT。所以先按扩展名筛掉,
80
+ * 再用 --help 输出确认是不是真的 whisper(见 looksLikeWhisper)。
81
+ * .bat/.cmd 也排除:spawnSync 不带 shell 无法直接执行它们。
82
+ */
83
+ function spawnable(p) {
84
+ if (!p) return false
85
+ const ext = path.extname(p).toLowerCase()
86
+ if (IS_WIN && ['.cpl', '.dll', '.msi', '.sys', '.scr', '.bat', '.cmd', '.ps1', '.vbs', '.js'].includes(ext)) return false
87
+ const looksPath = p.includes('/') || p.includes('\\')
88
+ if (looksPath && !fs.existsSync(p)) return false
89
+ try {
90
+ if (fs.existsSync(p)) {
91
+ const st = fs.statSync(p)
92
+ if (!st.isFile() || st.size === 0) return false
93
+ }
94
+ } catch { return false }
95
+ return true
96
+ }
97
+
98
+ // whisper.cpp(含旧版 main)和 openai-whisper 的帮助里一定带这些参数之一
99
+ function looksLikeWhisper(text) {
100
+ const t = String(text ?? '')
101
+ if (!t) return false
102
+ if (/--output[_-](?:txt|json|srt|vtt)|--output[_-]format|--output[_-]dir|--word[_-]timestamps/i.test(t)) return true
103
+ if (/(?:^|[\s,])-o(?:txt|json|srt|vtt)(?:[\s,]|$)/m.test(t)) return true
104
+ // whisper.cpp 的 -m/--model 与 -f/--file 组合;openai-whisper 没有 -f
105
+ if (/(?:-m|--model)\s+\S+/i.test(t) && /(?:-f|--file)\s+\S+/i.test(t)) return true
106
+ return false
107
+ }
108
+
109
+ // 两套参数体系:whisper.cpp 一套,openai-whisper(pip 装的 whisper 命令)一套
110
+ function detectKind(text, cmd) {
111
+ const t = String(text ?? '')
112
+ if (/--output[_-]dir|--output[_-]format|--word[_-]timestamps/i.test(t)) return 'openai'
113
+ if (/--output-txt|--output-json|--output-srt|--print-progress|--no-prints|whisper\.cpp/i.test(t)) return 'cpp'
114
+ const base = path.basename(String(cmd ?? '')).toLowerCase().replace(/\.exe$/, '')
115
+ if (base === 'main' || base.startsWith('whisper-cli')) return 'cpp'
116
+ return 'auto'
117
+ }
118
+
119
+ function inspect(cmd) {
120
+ const key = String(cmd).toLowerCase()
121
+ if (helpCache.has(key)) return helpCache.get(key)
122
+ let r = capture(cmd, ['--help'], 8000)
123
+ // 少数构建只认 -h,或 --help 什么都不打
124
+ if (!r.out.trim()) r = capture(cmd, ['-h'], 8000)
125
+ const info = {
126
+ ok: r.ok,
127
+ status: r.status,
128
+ error: r.error,
129
+ text: r.out,
130
+ whisper: looksLikeWhisper(r.out),
131
+ kind: detectKind(r.out, cmd),
132
+ supportsJson: /--output-json|(?:^|[\s,])-oj(?:[\s,]|$)/m.test(r.out),
133
+ }
134
+ helpCache.set(key, info)
135
+ return info
136
+ }
137
+
138
+ /** 常见安装路径:先看已知目录,再让 where 兜底 */
139
+ function commonWhisperPaths(env = process.env) {
140
+ const home = os.homedir()
141
+ const localApp = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local')
142
+ const names = WHISPER_NAMES.map(EXE)
143
+ const dirs = [
144
+ path.join(home, '.dsh', 'surfcore', 'whisper'),
145
+ path.join(home, '.dsh', 'surfcore', 'whisper', 'bin'),
146
+ path.join(home, '.dsh', 'surfcore', 'whisper', 'build', 'bin'),
147
+ path.join(home, '.dsh', 'surfcore', 'whisper', 'build', 'bin', 'Release'),
148
+ // 本机实际布局:ffmpeg 和 B站下载器放在 bili/bin,whisper 也可能被丢在同一处
149
+ path.join(home, '.dsh', 'surfcore', 'bili', 'bin'),
150
+ path.join(home, '.dsh', 'surfcore', 'bin'),
151
+ path.join(home, 'whisper.cpp'),
152
+ path.join(home, 'whisper.cpp', 'build', 'bin'),
153
+ path.join(home, 'whisper.cpp', 'build', 'bin', 'Release'),
154
+ path.join(home, 'Documents', 'whisper.cpp', 'build', 'bin', 'Release'),
155
+ path.join(home, 'Documents', 'deepseek-harness', 'whisper'),
156
+ path.join(localApp, 'whisper'),
157
+ path.join(localApp, 'Programs', 'whisper'),
158
+ path.join(PKG_DIR, 'bin'),
159
+ path.join(PKG_DIR, '..', 'whisper'),
160
+ 'C:\\whisper.cpp',
161
+ 'C:\\whisper.cpp\\build\\bin\\Release',
162
+ 'C:\\Tools\\whisper',
163
+ ]
164
+ const out = []
165
+ const seen = new Set()
166
+ for (const d of dirs) {
167
+ for (const n of names) {
168
+ const p = path.join(d, n)
169
+ const key = p.toLowerCase()
170
+ if (seen.has(key)) continue
171
+ seen.add(key)
172
+ out.push(p)
173
+ }
174
+ }
175
+ return out
176
+ }
177
+
178
+ // 环境变量给的可能是完整路径、带引号的路径,或 PATH 上的命令名
179
+ function resolveEnvCommand(raw, notes) {
180
+ const v = stripQuotes(raw)
181
+ if (!v) return null
182
+ if (fs.existsSync(v)) return v
183
+ if (path.isAbsolute(v) || v.includes('/') || v.includes('\\')) {
184
+ notes.push(`FISHEYE_WHISPER 指向的文件不存在:${v}`)
185
+ return null
186
+ }
187
+ const hits = whereAll(v).filter(spawnable)
188
+ if (hits.length) return hits[0]
189
+ notes.push(`FISHEYE_WHISPER=${v} 在 PATH 上找不到`)
190
+ return null
191
+ }
192
+
193
+ function modelDirs(cmd, env = process.env) {
194
+ const home = os.homedir()
195
+ const localApp = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local')
196
+ const dirs = []
197
+ // whisper 可执行文件旁边 / build/bin 往上一层的 models/
198
+ if (cmd && (cmd.includes('/') || cmd.includes('\\'))) {
199
+ const d = path.dirname(cmd)
200
+ dirs.push(d, path.join(d, 'models'), path.join(d, '..', 'models'), path.join(d, '..', '..', 'models'))
201
+ }
202
+ const explicitDir = stripQuotes(env.FISHEYE_WHISPER_MODEL_DIR)
203
+ if (explicitDir) dirs.unshift(explicitDir)
204
+ dirs.push(
205
+ path.join(home, '.dsh', 'surfcore', 'whisper'),
206
+ path.join(home, '.dsh', 'surfcore', 'whisper', 'models'),
207
+ path.join(home, '.dsh', 'surfcore', 'bili', 'models'),
208
+ path.join(home, '.cache', 'whisper'),
209
+ path.join(home, 'whisper.cpp', 'models'),
210
+ path.join(home, 'Documents', 'whisper.cpp', 'models'),
211
+ path.join(home, 'Documents', 'deepseek-harness', 'whisper', 'models'),
212
+ path.join(localApp, 'whisper', 'models'),
213
+ path.join(PKG_DIR, 'models'),
214
+ path.join(PKG_DIR, '..', 'models'),
215
+ 'C:\\whisper.cpp\\models',
216
+ 'C:\\Tools\\whisper\\models',
217
+ )
218
+ const out = []
219
+ const seen = new Set()
220
+ for (const d of dirs) {
221
+ try {
222
+ if (!fs.existsSync(d) || !fs.statSync(d).isDirectory()) continue
223
+ const key = d.toLowerCase()
224
+ if (seen.has(key)) continue
225
+ seen.add(key)
226
+ out.push(d)
227
+ } catch { /* 目录读不了就跳过 */ }
228
+ }
229
+ return out
230
+ }
231
+
232
+ function isModelFile(name, kind) {
233
+ const n = String(name).toLowerCase()
234
+ if (kind === 'openai') return n.endsWith('.pt')
235
+ return /^ggml[-_].+\.(bin|ggml)$/.test(n) || /(whisper|model).*\.bin$/.test(n) || n.endsWith('.ggml')
236
+ }
237
+
238
+ // 同名档位里优先多语种:.en 模型会把中文语音转成英文
239
+ function rankModel(file) {
240
+ const n = path.basename(file).toLowerCase()
241
+ const i = MODEL_PRIORITY.findIndex((k) => n.includes(k))
242
+ let rank = i < 0 ? MODEL_PRIORITY.length : i
243
+ if (/(^|[-_.])en([-_.]|$)/.test(n)) rank += 0.5
244
+ return rank
245
+ }
246
+
247
+ function listModels(dirs, kind) {
248
+ const files = []
249
+ for (const d of dirs) {
250
+ let ents = []
251
+ try { ents = fs.readdirSync(d, { withFileTypes: true }) } catch { continue }
252
+ for (const e of ents) {
253
+ if (!e.isFile() || !isModelFile(e.name, kind)) continue
254
+ files.push(path.join(d, e.name))
255
+ }
256
+ }
257
+ return files
258
+ }
259
+
260
+ function scanModels(dirs, kind) {
261
+ const files = listModels(dirs, kind)
262
+ if (!files.length) return null
263
+ files.sort((a, b) => rankModel(a) - rankModel(b) || a.localeCompare(b))
264
+ return files[0]
265
+ }
266
+
267
+ function findNamedModel(dirs, name, kind) {
268
+ const want = String(name).toLowerCase()
269
+ const hits = listModels(dirs, kind).filter((f) => path.basename(f).toLowerCase().includes(want))
270
+ if (!hits.length) return null
271
+ hits.sort((a, b) => rankModel(a) - rankModel(b) || a.localeCompare(b))
272
+ return hits[0]
273
+ }
274
+
275
+ /**
276
+ * 找模型:
277
+ * · FISHEYE_WHISPER_MODEL 是文件 → 直接用(用户显式指定就尊重)
278
+ * · 是目录 → 在目录里扫
279
+ * · 是裸名字(base/small)→ 先在本机同名文件里匹配;openai-whisper 允许直接传名字
280
+ * · 没设 → 在常见目录里自动发现 ggml-*.bin(cpp)或 *.pt(openai)
281
+ */
282
+ function findModel({ cmd, kind, env = process.env }) {
283
+ const dirs = modelDirs(cmd, env)
284
+ const wanted = stripQuotes(env.FISHEYE_WHISPER_MODEL)
285
+
286
+ if (wanted) {
287
+ if (fs.existsSync(wanted)) {
288
+ try {
289
+ if (fs.statSync(wanted).isDirectory()) {
290
+ const m = scanModels([wanted, ...dirs], kind)
291
+ if (m) return { model: m, from: `${wanted}(目录)` }
292
+ return { model: null, error: `FISHEYE_WHISPER_MODEL 目录里没有可用模型:${wanted}` }
293
+ }
294
+ } catch { /* 落回按文件处理 */ }
295
+ return { model: wanted, from: 'FISHEYE_WHISPER_MODEL' }
296
+ }
297
+ if (!wanted.includes('/') && !wanted.includes('\\')) {
298
+ const named = findNamedModel(dirs, wanted, kind)
299
+ if (named) return { model: named, from: `FISHEYE_WHISPER_MODEL=${wanted}(本地匹配)` }
300
+ if (kind === 'openai') return { model: wanted, from: `FISHEYE_WHISPER_MODEL=${wanted}(模型名)` }
301
+ return { model: null, error: `FISHEYE_WHISPER_MODEL=${wanted} 在本机找不到对应模型(需要 ggml-*.bin 的完整路径)` }
302
+ }
303
+ return { model: null, error: `FISHEYE_WHISPER_MODEL 指向的文件不存在:${wanted}` }
304
+ }
305
+
306
+ const found = scanModels(dirs, kind)
307
+ if (found) return { model: found, from: path.dirname(found) }
308
+ // openai-whisper 可以把模型名交给命令自己下载;whisper.cpp 必须有本地 ggml 文件
309
+ if (kind === 'openai') return { model: 'base', from: '默认 base(首次运行会下载)' }
310
+ return { model: null, error: '没找到 ggml-*.bin 模型(放到 ~/.dsh/surfcore/whisper/,或用 FISHEYE_WHISPER_MODEL 指定)' }
311
+ }
312
+
313
+ function buildWhisper(cmd, info, source, env) {
314
+ const m = findModel({ cmd, kind: info.kind, env })
315
+ return {
316
+ cmd,
317
+ model: m.model,
318
+ modelFrom: m.from ?? null,
319
+ modelError: m.error ?? null,
320
+ kind: info.kind,
321
+ source,
322
+ supportsJson: info.supportsJson === true,
323
+ }
324
+ }
325
+
326
+ function findWhisperDetailed({ env = process.env } = {}) {
327
+ const notes = []
328
+
329
+ // 1) 环境变量:显式指定优先级最高
330
+ if (env.FISHEYE_WHISPER) {
331
+ const cmd = resolveEnvCommand(env.FISHEYE_WHISPER, notes)
332
+ if (cmd) {
333
+ const info = inspect(cmd)
334
+ if (info.whisper) {
335
+ return { whisper: buildWhisper(cmd, info, '环境变量 FISHEYE_WHISPER', env), notes }
336
+ }
337
+ const why = info.error?.code
338
+ ? `无法执行:${info.error.code}`
339
+ : (info.text.trim() ? '帮助输出不匹配' : '没有帮助输出')
340
+ notes.push(`FISHEYE_WHISPER=${stripQuotes(env.FISHEYE_WHISPER)} 不像 whisper(${why})`)
341
+ }
342
+ }
343
+
344
+ // 2) PATH:严格按 whisper-cli → whisper → main 的顺序
345
+ for (const name of WHISPER_NAMES) {
346
+ const all = whereAll(name)
347
+ const hits = all.filter(spawnable)
348
+ if (all.length && !hits.length) {
349
+ notes.push(`PATH 上的 ${name} 只命中了 ${path.basename(all[0]).toLowerCase()}(不是 whisper,已忽略)`)
350
+ }
351
+ for (const cmd of hits) {
352
+ const info = inspect(cmd)
353
+ if (info.whisper) {
354
+ return { whisper: buildWhisper(cmd, info, `PATH(${name})`, env), notes }
355
+ }
356
+ }
357
+ }
358
+
359
+ // 3) 常见安装路径(用户手动解压 whisper.cpp 时它通常不在 PATH 里)
360
+ for (const cmd of commonWhisperPaths(env)) {
361
+ if (!fs.existsSync(cmd) || !spawnable(cmd)) continue
362
+ const info = inspect(cmd)
363
+ if (info.whisper) {
364
+ return { whisper: buildWhisper(cmd, info, '常见安装路径', env), notes }
365
+ }
366
+ }
367
+
368
+ return { whisper: null, notes }
369
+ }
370
+
371
+ /**
372
+ * 探测可用的 whisper。
373
+ * @param {{ ffmpeg?: string, env?: NodeJS.ProcessEnv }} [opts]
374
+ * @returns {{ cmd: string, model: string|null, kind: 'cpp'|'openai'|'auto', source: string,
375
+ * supportsJson: boolean, modelFrom: string|null, modelError: string|null } | null}
376
+ */
377
+ export function findWhisper({ ffmpeg, env } = {}) {
378
+ void ffmpeg // 抽音频时才用;保留参数是为了和 audioCaps/transcribeAudio 的调用签名一致
379
+ const r = findWhisperDetailed({ env: env ?? process.env })
380
+ if (!r.whisper) return null
381
+ const w = r.whisper
382
+ return {
383
+ cmd: w.cmd,
384
+ model: w.model,
385
+ kind: w.kind,
386
+ source: w.source,
387
+ supportsJson: w.supportsJson,
388
+ modelFrom: w.modelFrom,
389
+ modelError: w.modelError,
390
+ }
391
+ }
392
+
393
+ /** ffmpeg 解析:显式参数 → FFMPEG 环境变量 → env.mjs(可能为 null)→ 本机常见目录 → PATH */
394
+ function resolveFfmpeg({ env = process.env, explicit } = {}) {
395
+ const home = os.homedir()
396
+ const cands = [
397
+ explicit,
398
+ env.FFMPEG,
399
+ FFMPEG,
400
+ path.join(home, '.dsh', 'surfcore', 'bili', 'bin', EXE('ffmpeg')),
401
+ path.join(home, '.dsh', 'surfcore', 'bin', EXE('ffmpeg')),
402
+ path.join(PKG_DIR, 'bin', EXE('ffmpeg')),
403
+ ].filter(Boolean).map(stripQuotes)
404
+
405
+ for (const c of cands) {
406
+ try {
407
+ if (fs.existsSync(c) && fs.statSync(c).isFile()) return c
408
+ } catch { /* 下一个 */ }
409
+ }
410
+ return whereAll('ffmpeg').filter(spawnable)[0] ?? null
411
+ }
412
+
413
+ /**
414
+ * 能力描述(给 CLI / selftest 打印用)。
415
+ * available 为 true 才表示「现在真的能转写」:whisper + 模型 + ffmpeg 三者齐了。
416
+ * @param {{ ffmpeg?: string, env?: NodeJS.ProcessEnv, whisper?: object }} [options]
417
+ * @returns {{ available: boolean, reason: string, summary: string, hint: string|null,
418
+ * whisper: boolean, ffmpeg: string|null, cmd: string|null, model: string|null,
419
+ * kind: string|null, notes: string[] }}
420
+ */
421
+ export function audioCaps(options = {}) {
422
+ const env = options.env ?? process.env
423
+ try {
424
+ const ffmpeg = resolveFfmpeg({ env, explicit: options.ffmpeg })
425
+ const detail = options.whisper
426
+ ? { whisper: options.whisper, notes: [] }
427
+ : findWhisperDetailed({ env })
428
+ const w = detail.whisper
429
+ const caps = {
430
+ available: false,
431
+ reason: '',
432
+ summary: '',
433
+ hint: null,
434
+ whisper: Boolean(w),
435
+ ffmpeg: ffmpeg ?? null,
436
+ cmd: w?.cmd ?? null,
437
+ model: w?.model ?? null,
438
+ kind: w?.kind ?? null,
439
+ notes: detail.notes ?? [],
440
+ }
441
+
442
+ if (!w) {
443
+ caps.reason = '未找到 whisper 可执行文件(已试 FISHEYE_WHISPER / whisper-cli / whisper / main / 常见安装路径)'
444
+ caps.hint = '装 whisper.cpp 后设 FISHEYE_WHISPER=<whisper-cli 或 main 的完整路径>;'
445
+ + '模型放 ~/.dsh/surfcore/whisper/,或用 FISHEYE_WHISPER_MODEL 指定 ggml-*.bin'
446
+ } else if (!w.model) {
447
+ caps.reason = w.modelError || '找到 whisper,但找不到可用的模型'
448
+ caps.hint = '把 ggml-*.bin 放到 ~/.dsh/surfcore/whisper/,或设 FISHEYE_WHISPER_MODEL=<模型完整路径>'
449
+ } else if (!ffmpeg) {
450
+ caps.reason = '找到 whisper,但缺少 ffmpeg(抽 16kHz wav 必需)'
451
+ caps.hint = '设 FFMPEG=<ffmpeg 完整路径>,或把 ffmpeg 装到 PATH'
452
+ } else {
453
+ caps.available = true
454
+ const bits = [`whisper ${path.basename(w.cmd)}(${w.source})`, `模型 ${path.basename(w.model)}`]
455
+ if (w.modelFrom && w.modelFrom !== path.dirname(w.model)) bits.push(w.modelFrom)
456
+ bits.push(`ffmpeg ${path.basename(ffmpeg)}`)
457
+ caps.reason = bits.join(',')
458
+ }
459
+
460
+ caps.summary = `音频转写:${caps.available ? '可用' : '不可用'} —— ${caps.reason}`
461
+ return caps
462
+ } catch (e) {
463
+ return {
464
+ available: false,
465
+ reason: `能力探测异常:${String(e?.message ?? e).slice(0, 120)}`,
466
+ summary: `音频转写:不可用 —— 能力探测异常`,
467
+ hint: null,
468
+ whisper: false,
469
+ ffmpeg: null,
470
+ cmd: null,
471
+ model: null,
472
+ kind: null,
473
+ notes: [],
474
+ }
475
+ }
476
+ }
477
+
478
+ function buildWhisperArgs(kind, { model, wav, outDir, prefix, lang, supportsJson }) {
479
+ if (kind === 'cpp') {
480
+ // whisper.cpp / whisper-cli:-otxt 落 txt;-oj 只有新版支持(help 里探测过才加)
481
+ const args = ['-m', model, '-f', wav, '-otxt', '-of', prefix]
482
+ if (lang && lang !== 'auto') args.push('-l', String(lang))
483
+ if (supportsJson) args.push('-oj')
484
+ return args
485
+ }
486
+ // openai-whisper:输入文件在前,模型可以是名字(base/small)或 .pt 路径
487
+ const args = [wav, '--model', model, '--output_dir', outDir, '--output_format', 'txt']
488
+ if (lang && lang !== 'auto') args.push('--language', String(lang))
489
+ return args
490
+ }
491
+
492
+ function findTranscript(kind, { prefix, wav, outDir }) {
493
+ const cands = kind === 'cpp'
494
+ ? [`${prefix}.txt`]
495
+ : [path.join(outDir, `${path.basename(wav, path.extname(wav))}.txt`), `${prefix}.txt`]
496
+ for (const f of cands) {
497
+ try {
498
+ if (fs.existsSync(f) && fs.statSync(f).size > 0) return f
499
+ } catch { /* 下一个 */ }
500
+ }
501
+ return null
502
+ }
503
+
504
+ // whisper.cpp 的 -oj 输出 { transcription: [{ offsets: {from,to}, text }] },offset 是毫秒
505
+ function readSegments(jsonFile) {
506
+ try {
507
+ const j = JSON.parse(readText(jsonFile))
508
+ const arr = Array.isArray(j)
509
+ ? j
510
+ : (Array.isArray(j?.transcription) ? j.transcription : (Array.isArray(j?.segments) ? j.segments : []))
511
+ const segs = []
512
+ for (const s of arr) {
513
+ const text = String(s?.text ?? '').trim()
514
+ if (!text) continue
515
+ const start = typeof s?.start === 'number' ? s.start
516
+ : (typeof s?.offsets?.from === 'number' ? s.offsets.from / 1000 : undefined)
517
+ const end = typeof s?.end === 'number' ? s.end
518
+ : (typeof s?.offsets?.to === 'number' ? s.offsets.to / 1000 : undefined)
519
+ const seg = { text }
520
+ if (Number.isFinite(start)) seg.start = Number(start.toFixed(3))
521
+ if (Number.isFinite(end)) seg.end = Number(end.toFixed(3))
522
+ segs.push(seg)
523
+ }
524
+ return segs.length ? segs : null
525
+ } catch { return null }
526
+ }
527
+
528
+ function normalizeText(raw) {
529
+ return String(raw ?? '')
530
+ .replace(/^\uFEFF/, '')
531
+ .split(/\r?\n/)
532
+ .map((l) => l.trim())
533
+ .join('\n')
534
+ .replace(/\n{3,}/g, '\n\n')
535
+ .trim()
536
+ }
537
+
538
+ /**
539
+ * 转写一个视频里的音频(可选能力,失败不抛异常)。
540
+ *
541
+ * @param {string} mp4Path 视频文件(mp4/mkv/... 只要有音轨)
542
+ * @param {string} outDir 产物目录:txt 留在这里,wav 默认用完删除
543
+ * @param {{
544
+ * ffmpeg?: string, whisper?: object, model?: string, env?: NodeJS.ProcessEnv,
545
+ * lang?: string, ffmpegTimeoutMs?: number, timeoutMs?: number,
546
+ * keepWav?: boolean, quiet?: boolean
547
+ * }} [options]
548
+ * @returns {{ text: string, segments?: { start?: number, end?: number, text: string }[],
549
+ * cmd: string, model: string, kind: string, file: string } | null}
550
+ */
551
+ export function transcribeAudio(mp4Path, outDir, options = {}) {
552
+ const env = options.env ?? process.env
553
+ const quiet = options.quiet === true
554
+ const warn = (msg) => { if (!quiet) console.warn(`[音频] ${msg}`) }
555
+ let wav = null
556
+
557
+ try {
558
+ const file = mp4Path ? path.resolve(String(mp4Path)) : ''
559
+ if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
560
+ warn(`找不到视频文件:${mp4Path ?? '(空)'}`)
561
+ return null
562
+ }
563
+
564
+ const ffmpeg = resolveFfmpeg({ env, explicit: options.ffmpeg })
565
+ if (!ffmpeg) {
566
+ warn('没有 ffmpeg,跳过音频转写(设 FFMPEG 环境变量或把 ffmpeg 装到 PATH)')
567
+ return null
568
+ }
569
+
570
+ let w = options.whisper ?? findWhisper({ ffmpeg, env })
571
+ if (!w) {
572
+ warn('没有可用的 whisper,跳过音频转写(装 whisper.cpp 或设 FISHEYE_WHISPER)')
573
+ return null
574
+ }
575
+ if (options.model) w = { ...w, model: options.model, modelFrom: 'options.model', modelError: null }
576
+ if (!w.model) {
577
+ warn(`找到 ${path.basename(w.cmd)},但没有模型:${w.modelError ?? '未知原因'}(用 FISHEYE_WHISPER_MODEL 指定)`)
578
+ return null
579
+ }
580
+
581
+ const dir = path.resolve(outDir || path.join(os.tmpdir(), 'fisheye-audio'))
582
+ fs.mkdirSync(dir, { recursive: true })
583
+ const id = `${process.pid}-${Date.now()}-${crypto.randomUUID().slice(0, 6)}`
584
+ wav = path.join(dir, `audio-${id}.wav`)
585
+ const prefix = path.join(dir, `audio-${id}`)
586
+
587
+ // 1) 抽音频:16kHz 单声道 wav(whisper 全家都吃这个格式)
588
+ const ff = spawnSync(ffmpeg, [
589
+ '-y', '-loglevel', 'error', '-i', file,
590
+ '-vn', '-ac', '1', '-ar', '16000', '-f', 'wav', wav,
591
+ ], { stdio: 'ignore', timeout: options.ffmpegTimeoutMs ?? 900000, windowsHide: true })
592
+
593
+ if (ff.status !== 0 || !fs.existsSync(wav)) {
594
+ warn('抽取音频失败(视频可能没有音轨,或 ffmpeg 解码出错)')
595
+ return null
596
+ }
597
+ if (fs.statSync(wav).size <= 1024) {
598
+ warn('抽出来的音频几乎为空(视频可能没有音轨)')
599
+ return null
600
+ }
601
+
602
+ // 2) 跑 whisper。kind 未知(auto)时两套参数都试一遍,反正失败得很快
603
+ const order = w.kind === 'cpp' ? ['cpp'] : w.kind === 'openai' ? ['openai'] : ['cpp', 'openai']
604
+ const failures = []
605
+ for (const kind of order) {
606
+ const args = buildWhisperArgs(kind, {
607
+ model: w.model,
608
+ wav,
609
+ outDir: dir,
610
+ prefix,
611
+ lang: options.lang,
612
+ supportsJson: w.supportsJson,
613
+ })
614
+ const run = spawnSync(w.cmd, args, {
615
+ stdio: 'ignore',
616
+ timeout: options.timeoutMs ?? 1800000,
617
+ windowsHide: true,
618
+ })
619
+ const txt = findTranscript(kind, { prefix, wav, outDir: dir })
620
+ if (txt) {
621
+ const text = normalizeText(readText(txt))
622
+ const segments = kind === 'cpp' ? readSegments(`${prefix}.json`) : null
623
+ if (!text) warn('转写结果为空(视频里可能没有可识别的人声)')
624
+ const out = { text, cmd: w.cmd, model: w.model, kind, file: txt }
625
+ if (segments) out.segments = segments
626
+ return out
627
+ }
628
+ const why = run.error?.code === 'ETIMEDOUT'
629
+ ? '超时'
630
+ : (run.error?.code ?? `退出码 ${run.status}`)
631
+ failures.push(`${kind} 参数失败(${why})`)
632
+ }
633
+
634
+ warn(`whisper 没有产出文本:${failures.join(';')}(可先用 --help 看这版参数)`)
635
+ return null
636
+ } catch (e) {
637
+ warn(`转写异常:${String(e?.message ?? e).split('\n')[0].slice(0, 120)}`)
638
+ return null
639
+ } finally {
640
+ // wav 是纯中间产物,默认清掉;调试时传 keepWav:true 留证据
641
+ if (wav && options.keepWav !== true) {
642
+ try { fs.unlinkSync(wav) } catch {}
643
+ }
644
+ }
645
+ }