@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/CHANGELOG.md +31 -0
- package/README.md +12 -0
- package/fisheye.mjs +9 -34
- package/lib/audio.mjs +645 -0
- package/lib/bili.mjs +19 -18
- package/lib/budget.mjs +117 -0
- package/lib/env.mjs +1 -0
- package/lib/i18n.mjs +131 -0
- package/lib/retry.mjs +209 -0
- package/lib/stages.mjs +34 -13
- package/lib/util.mjs +9 -8
- package/package.json +1 -1
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
|
+
}
|