@hxnnxs/opencode-voice 0.1.7 → 0.1.9
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 +16 -0
- package/README.md +2 -2
- package/bin/opencode-voice.js +61 -10
- package/docs/README.es.md +2 -2
- package/docs/README.ru.md +2 -2
- package/docs/README.zh.md +2 -2
- package/index.js +62 -4
- package/lib/engine.js +401 -22
- package/lib/engines.js +85 -36
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.9 - 2026-06-21
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Rebuilt the managed Windows `whisper-cli.exe` engine asset as a static MinGW binary so clean Windows installs do not need the Visual C++ runtime DLLs.
|
|
10
|
+
- Fixed managed engine path handling so explicit `downloadDir` and Windows platform overrides are honored consistently.
|
|
11
|
+
- Added streaming timeout protection and clearer VC++ runtime diagnostics to managed engine downloads and probes.
|
|
12
|
+
|
|
13
|
+
## 0.1.8 - 2026-06-21
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Added a managed Windows recorder install: the plugin can now download, unpack, cache, and probe `ffmpeg.exe` itself before recording, so Windows voice input no longer depends on `ffmpeg-static` lifecycle scripts, npm recovery installs, or a user-installed recorder on `PATH`.
|
|
18
|
+
- Extended `opencode-voice doctor` diagnostics to prepare and report the managed Windows recorder path, manifest, install error, and probe result.
|
|
19
|
+
- Made managed native binary replacement retry-safe on Windows for both recorder and `whisper-cli` engine installs.
|
|
20
|
+
|
|
5
21
|
## 0.1.7 - 2026-06-17
|
|
6
22
|
|
|
7
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ Planned sidecar models:
|
|
|
112
112
|
| -------- | ------ |
|
|
113
113
|
| Linux | one-command engine/model install; recording uses `arecord`, `ffmpeg`, or `sox` |
|
|
114
114
|
| macOS | one-command engine/model install; recording uses `ffmpeg` AVFoundation until the native recorder sidecar ships |
|
|
115
|
-
| Windows | one-command engine/model install; recording uses `ffmpeg
|
|
115
|
+
| Windows | one-command engine/model/recorder install; recording uses DirectShow through a managed cached `ffmpeg.exe`, with system/bundled ffmpeg fallback |
|
|
116
116
|
|
|
117
117
|
### Architecture
|
|
118
118
|
|
|
@@ -128,7 +128,7 @@ Files:
|
|
|
128
128
|
- `index.js` - TUI plugin entrypoint, commands, dialogs, keymap layer
|
|
129
129
|
- `lib/models.js` - model registry, cache paths, default settings
|
|
130
130
|
- `lib/download.js` - resumable model download and SHA256 verification
|
|
131
|
-
- `lib/engine.js` - recorder selection and `whisper-cli` transcription
|
|
131
|
+
- `lib/engine.js` - recorder selection, managed Windows recorder install, and `whisper-cli` transcription
|
|
132
132
|
- `lib/engines.js` - managed native engine download, status, import, and removal
|
|
133
133
|
- `bin/opencode-voice.js` - install wrapper and diagnostics CLI
|
|
134
134
|
|
package/bin/opencode-voice.js
CHANGED
|
@@ -67,21 +67,41 @@ async function runtime() {
|
|
|
67
67
|
|
|
68
68
|
async function doctor() {
|
|
69
69
|
const {
|
|
70
|
-
|
|
70
|
+
ensureManagedRecorder,
|
|
71
71
|
resolveCommand,
|
|
72
72
|
getAudioDir,
|
|
73
73
|
getCacheDir,
|
|
74
74
|
getEngineStatus,
|
|
75
75
|
getModelsDir,
|
|
76
|
+
getRecorderStatus,
|
|
76
77
|
listMicrophones,
|
|
77
78
|
probeEngine,
|
|
78
79
|
} = await runtime();
|
|
79
80
|
|
|
81
|
+
const json = hasFlag("--json");
|
|
82
|
+
let recorderInstall = null;
|
|
83
|
+
let recorderInstallError = "";
|
|
84
|
+
if (process.platform === "win32") {
|
|
85
|
+
try {
|
|
86
|
+
recorderInstall = await ensureManagedRecorder(
|
|
87
|
+
{},
|
|
88
|
+
{},
|
|
89
|
+
json ? {} : { onProgress: printRecorderProgress, onRetry: printRecorderRetry },
|
|
90
|
+
);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
recorderInstallError = error instanceof Error ? error.message : String(error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const recorderOptions = process.platform === "win32" && recorderInstall?.resolvedBinary
|
|
97
|
+
? { ffmpeg: recorderInstall.resolvedBinary, skipFfmpegStaticInstall: true }
|
|
98
|
+
: {};
|
|
80
99
|
const engine = getEngineStatus("whisper.cpp");
|
|
81
100
|
const probe = engine.resolvedBinary ? await probeEngine("whisper.cpp", engine.resolvedBinary) : { ok: false, message: "missing binary" };
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
101
|
+
const recorder = getRecorderStatus(recorderOptions);
|
|
102
|
+
const ffmpeg = recorderInstall?.resolvedBinary || resolveCommand("ffmpeg", recorderOptions);
|
|
103
|
+
const arecord = resolveCommand("arecord", recorderOptions);
|
|
104
|
+
const sox = resolveCommand("sox", recorderOptions);
|
|
85
105
|
const payload = {
|
|
86
106
|
platform: `${process.platform}-${process.arch}`,
|
|
87
107
|
cacheDir: getCacheDir(),
|
|
@@ -89,21 +109,24 @@ async function doctor() {
|
|
|
89
109
|
recordingsDir: getAudioDir(),
|
|
90
110
|
engine,
|
|
91
111
|
probe,
|
|
112
|
+
recorder,
|
|
92
113
|
recorders: {
|
|
93
114
|
ffmpeg,
|
|
94
115
|
arecord,
|
|
95
116
|
sox,
|
|
96
|
-
ffmpegPresent:
|
|
97
|
-
arecordPresent:
|
|
98
|
-
soxPresent:
|
|
117
|
+
ffmpegPresent: Boolean(ffmpeg),
|
|
118
|
+
arecordPresent: Boolean(arecord),
|
|
119
|
+
soxPresent: Boolean(sox),
|
|
99
120
|
ffmpegProbe: probeCommand(ffmpeg),
|
|
100
121
|
arecordProbe: probeCommand(arecord, ["--help"]),
|
|
101
122
|
soxProbe: probeCommand(sox, ["--help"]),
|
|
123
|
+
managedInstall: recorderInstall,
|
|
124
|
+
managedInstallError: recorderInstallError,
|
|
102
125
|
},
|
|
103
|
-
microphones: listMicrophones(),
|
|
126
|
+
microphones: listMicrophones(recorderOptions),
|
|
104
127
|
};
|
|
105
128
|
|
|
106
|
-
if (
|
|
129
|
+
if (json) {
|
|
107
130
|
console.log(JSON.stringify(payload, null, 2));
|
|
108
131
|
} else {
|
|
109
132
|
console.log(
|
|
@@ -118,13 +141,19 @@ async function doctor() {
|
|
|
118
141
|
`Managed engine dir: ${engine.managedDir}`,
|
|
119
142
|
`whisper-cli: ${engine.resolvedBinary || "missing"}`,
|
|
120
143
|
`Probe: ${probe.ok ? "ok" : probe.message}`,
|
|
144
|
+
`Recorder source: ${recorder.source}`,
|
|
145
|
+
`Managed recorder dir: ${recorder.managedDir}`,
|
|
146
|
+
`Managed recorder installed: ${recorder.managedInstalled ? "yes" : "no"}`,
|
|
147
|
+
`Managed recorder version: ${recorder.manifest?.version || "missing"}`,
|
|
148
|
+
`Managed recorder probe: ${payload.recorders.ffmpegProbe.ok ? "ok" : payload.recorders.ffmpegProbe.message}`,
|
|
149
|
+
...(recorderInstallError ? [`Managed recorder error: ${recorderInstallError}`] : []),
|
|
121
150
|
`Recorders: ffmpeg=${payload.recorders.ffmpegPresent ? "yes" : "no"}${payload.recorders.ffmpeg ? ` (${payload.recorders.ffmpeg})` : ""}, arecord=${payload.recorders.arecordPresent ? "yes" : "no"}${payload.recorders.arecord ? ` (${payload.recorders.arecord})` : ""}, sox=${payload.recorders.soxPresent ? "yes" : "no"}${payload.recorders.sox ? ` (${payload.recorders.sox})` : ""}`,
|
|
122
151
|
`Microphones: ${payload.microphones.join(", ")}`,
|
|
123
152
|
].join("\n"),
|
|
124
153
|
);
|
|
125
154
|
}
|
|
126
155
|
|
|
127
|
-
if (!engine.resolvedBinary || !probe.ok) process.exitCode = 1;
|
|
156
|
+
if (!engine.resolvedBinary || !probe.ok || (process.platform === "win32" && !payload.recorders.ffmpegProbe.ok)) process.exitCode = 1;
|
|
128
157
|
}
|
|
129
158
|
|
|
130
159
|
async function engineCommand() {
|
|
@@ -198,6 +227,21 @@ function printEngineRetry({ error, nextAttempt, attempts }) {
|
|
|
198
227
|
console.warn(`engine retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`);
|
|
199
228
|
}
|
|
200
229
|
|
|
230
|
+
function printRecorderProgress(progress) {
|
|
231
|
+
const label = {
|
|
232
|
+
downloading: "download",
|
|
233
|
+
decompressing: "unpack",
|
|
234
|
+
probing: "probe",
|
|
235
|
+
done: "done",
|
|
236
|
+
}[progress.state] || progress.state || "recorder";
|
|
237
|
+
const percent = Number.isFinite(progress.percent) ? `${Math.round(progress.percent)}%` : "";
|
|
238
|
+
if (progress.state === "downloading" || progress.state === "done") console.log(`recorder ${label} ${percent}`.trim());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function printRecorderRetry({ error, nextAttempt, attempts }) {
|
|
242
|
+
console.warn(`recorder retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
201
245
|
async function installCommand() {
|
|
202
246
|
const pluginArgs = args.filter((arg) => arg !== "--no-engine");
|
|
203
247
|
const spawnOptions = { stdio: "inherit" };
|
|
@@ -215,6 +259,13 @@ async function installCommand() {
|
|
|
215
259
|
const engine = await installManagedEngine("whisper.cpp", {}, {}, { onProgress: printEngineProgress, onRetry: printEngineRetry });
|
|
216
260
|
console.log(`Managed voice engine ready: ${engine.managedBinary}`);
|
|
217
261
|
}
|
|
262
|
+
|
|
263
|
+
if (process.platform === "win32") {
|
|
264
|
+
const { ensureManagedRecorder } = await runtime();
|
|
265
|
+
console.log("Installing managed Windows recorder...");
|
|
266
|
+
const recorder = await ensureManagedRecorder({}, {}, { onProgress: printRecorderProgress, onRetry: printRecorderRetry });
|
|
267
|
+
console.log(`Managed Windows recorder ready: ${recorder.managedBinary || recorder.resolvedBinary}`);
|
|
268
|
+
}
|
|
218
269
|
}
|
|
219
270
|
|
|
220
271
|
if (command === "install") await installCommand();
|
package/docs/README.es.md
CHANGED
|
@@ -110,7 +110,7 @@ Modelos sidecar planeados:
|
|
|
110
110
|
| ---------- | ------ |
|
|
111
111
|
| Linux | instalación engine/model en una orden; la grabación usa `arecord`, `ffmpeg` o `sox` |
|
|
112
112
|
| macOS | instalación engine/model en una orden; la grabación usa `ffmpeg` AVFoundation hasta el native recorder sidecar |
|
|
113
|
-
| Windows | instalación one-command de engine/model; grabación con `ffmpeg`
|
|
113
|
+
| Windows | instalación one-command de engine/model/recorder; grabación con DirectShow mediante `ffmpeg.exe` managed en caché, con fallback a ffmpeg del sistema/incluido |
|
|
114
114
|
|
|
115
115
|
### Arquitectura
|
|
116
116
|
|
|
@@ -126,7 +126,7 @@ Archivos:
|
|
|
126
126
|
- `index.js` - entrada TUI, comandos, dialogs, keymap layer
|
|
127
127
|
- `lib/models.js` - registry de modelos, cache paths, default settings
|
|
128
128
|
- `lib/download.js` - descarga resumible y verificación SHA256
|
|
129
|
-
- `lib/engine.js` - selección de recorder y transcripción con `whisper-cli`
|
|
129
|
+
- `lib/engine.js` - selección de recorder, instalación managed del recorder Windows y transcripción con `whisper-cli`
|
|
130
130
|
- `lib/engines.js` - descarga, estado, importación y eliminación de managed native engine
|
|
131
131
|
- `bin/opencode-voice.js` - install wrapper y diagnostics CLI
|
|
132
132
|
|
package/docs/README.ru.md
CHANGED
|
@@ -110,7 +110,7 @@ Hold-to-talk отключен по умолчанию, потому что termi
|
|
|
110
110
|
| --------- | ------ |
|
|
111
111
|
| Linux | one-command engine/model install; запись использует `arecord`, `ffmpeg` или `sox` |
|
|
112
112
|
| macOS | one-command engine/model install; запись использует `ffmpeg` AVFoundation до native recorder sidecar |
|
|
113
|
-
| Windows | one-command engine/model install; запись через `ffmpeg
|
|
113
|
+
| Windows | one-command engine/model/recorder install; запись через DirectShow и managed cached `ffmpeg.exe`, с fallback на системный/встроенный ffmpeg |
|
|
114
114
|
|
|
115
115
|
### Архитектура
|
|
116
116
|
|
|
@@ -126,7 +126,7 @@ Hold-to-talk отключен по умолчанию, потому что termi
|
|
|
126
126
|
- `index.js` - TUI entrypoint, команды, dialogs, keymap layer
|
|
127
127
|
- `lib/models.js` - registry моделей, cache paths, default settings
|
|
128
128
|
- `lib/download.js` - resumable download и SHA256 verification
|
|
129
|
-
- `lib/engine.js` - выбор recorder и transcription через `whisper-cli`
|
|
129
|
+
- `lib/engine.js` - выбор recorder, managed Windows recorder install и transcription через `whisper-cli`
|
|
130
130
|
- `lib/engines.js` - managed native engine download, status, import и removal
|
|
131
131
|
- `bin/opencode-voice.js` - install wrapper и diagnostics CLI
|
|
132
132
|
|
package/docs/README.zh.md
CHANGED
|
@@ -110,7 +110,7 @@ ctrl+r -> 停止、转写并插入文本
|
|
|
110
110
|
| ------- | ---- |
|
|
111
111
|
| Linux | 一条命令安装 engine/model;录音使用 `arecord`、`ffmpeg` 或 `sox` |
|
|
112
112
|
| macOS | 一条命令安装 engine/model;native recorder sidecar 发布前使用 `ffmpeg` AVFoundation |
|
|
113
|
-
| Windows |
|
|
113
|
+
| Windows | 一条命令安装 engine/model/recorder;通过 managed cached `ffmpeg.exe` + DirectShow 录音,并保留系统/内置 ffmpeg 备用 |
|
|
114
114
|
|
|
115
115
|
### 架构
|
|
116
116
|
|
|
@@ -126,7 +126,7 @@ ctrl+r -> 停止、转写并插入文本
|
|
|
126
126
|
- `index.js` - TUI 插件入口、命令、dialogs、keymap layer
|
|
127
127
|
- `lib/models.js` - 模型 registry、cache paths、default settings
|
|
128
128
|
- `lib/download.js` - 可续传下载和 SHA256 校验
|
|
129
|
-
- `lib/engine.js` - recorder
|
|
129
|
+
- `lib/engine.js` - recorder 选择、managed Windows recorder 安装和 `whisper-cli` 转写
|
|
130
130
|
- `lib/engines.js` - managed native engine 下载、状态、导入和移除
|
|
131
131
|
- `bin/opencode-voice.js` - install wrapper 和 diagnostics CLI
|
|
132
132
|
|
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MODELS, DEFAULT_SETTINGS, PLUGIN_ID, formatSize, getCacheDir, getModel, getModelPath, isModelDownloaded, isModelFilePresent } from "./lib/models.js";
|
|
2
2
|
import { downloadModel } from "./lib/download.js";
|
|
3
|
-
import { VoiceRuntime,
|
|
3
|
+
import { VoiceRuntime, ensureManagedRecorder, getRecorderStatus, listMicrophones, probeRecorder, resolveCommand } from "./lib/engine.js";
|
|
4
4
|
import { getEngineStatus, importManagedEngine, installManagedEngine, probeEngine, removeManagedEngine } from "./lib/engines.js";
|
|
5
5
|
|
|
6
6
|
const KV = {
|
|
@@ -141,6 +141,33 @@ function renderEngineInstallStatus(ctx, progress = {}) {
|
|
|
141
141
|
);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
function renderRecorderInstallStatus(ctx, progress = {}) {
|
|
145
|
+
const percent = Math.max(0, Math.min(100, progress.percent || 0));
|
|
146
|
+
const state = {
|
|
147
|
+
downloading: "Downloading Windows recorder",
|
|
148
|
+
decompressing: "Unpacking ffmpeg",
|
|
149
|
+
probing: "Checking ffmpeg",
|
|
150
|
+
done: "Windows recorder ready",
|
|
151
|
+
}[progress.state] || "Preparing Windows recorder";
|
|
152
|
+
const attempt = progress.attempts > 1 ? `${progress.attempt} of ${progress.attempts}` : "single pass";
|
|
153
|
+
|
|
154
|
+
setDialog(ctx, "xlarge", () =>
|
|
155
|
+
ctx.api.ui.DialogAlert({
|
|
156
|
+
title: "Installing Windows recorder",
|
|
157
|
+
message: [
|
|
158
|
+
"ffmpeg DirectShow",
|
|
159
|
+
"",
|
|
160
|
+
state,
|
|
161
|
+
progressLine(percent),
|
|
162
|
+
"",
|
|
163
|
+
progress.total ? `${formatBytes(progress.downloaded || 0)} of ${formatBytes(progress.total)}` : "Fetching recorder binary",
|
|
164
|
+
`Attempt ${attempt}`,
|
|
165
|
+
"This is downloaded once into the managed opencode-voice cache.",
|
|
166
|
+
].join("\n"),
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
function modelStatus(model, options, settings) {
|
|
145
172
|
if (!model.implemented) return "planned";
|
|
146
173
|
if (isModelFilePresent(model, options, settings) && !isModelDownloaded(model, options, settings)) return "needs verification";
|
|
@@ -216,6 +243,30 @@ async function ensureEngineReady(ctx, settings) {
|
|
|
216
243
|
return true;
|
|
217
244
|
}
|
|
218
245
|
|
|
246
|
+
async function ensureRecorderReady(ctx, settings) {
|
|
247
|
+
if (process.platform !== "win32") return true;
|
|
248
|
+
|
|
249
|
+
const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir, skipFfmpegStaticInstall: true };
|
|
250
|
+
const current = getRecorderStatus(commandOptions, settings);
|
|
251
|
+
if (current.resolvedBinary) {
|
|
252
|
+
const probe = await probeRecorder(current.resolvedBinary);
|
|
253
|
+
if (probe.ok) return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
renderRecorderInstallStatus(ctx, { state: "downloading", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts: 5 });
|
|
257
|
+
toast(ctx.api, "Installing local Windows recorder...");
|
|
258
|
+
await ensureManagedRecorder(commandOptions, settings, {
|
|
259
|
+
retries: 5,
|
|
260
|
+
onProgress: (progress) => renderRecorderInstallStatus(ctx, progress),
|
|
261
|
+
onRetry: ({ error, nextAttempt, attempts }) => {
|
|
262
|
+
renderRecorderInstallStatus(ctx, { state: "downloading", downloaded: 0, total: 0, percent: 0, attempt: nextAttempt, attempts });
|
|
263
|
+
toast(ctx.api, `Recorder install retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
toast(ctx.api, "Windows recorder installed", "success");
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
219
270
|
function showModelPicker(ctx, firstRun = false) {
|
|
220
271
|
const settings = readSettings(ctx.api.kv);
|
|
221
272
|
setDialog(ctx, "large", () =>
|
|
@@ -325,8 +376,9 @@ function showLanguagePicker(ctx) {
|
|
|
325
376
|
|
|
326
377
|
function showMicrophonePicker(ctx) {
|
|
327
378
|
const settings = readSettings(ctx.api.kv);
|
|
379
|
+
const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir, skipFfmpegStaticInstall: true };
|
|
328
380
|
const placeholder = process.platform === "win32" ? "default, audio=default, \"Microphone (Name)\"" : "default, hw:0,0, pulse, :0, ...";
|
|
329
|
-
const devices = listMicrophones();
|
|
381
|
+
const devices = listMicrophones(commandOptions);
|
|
330
382
|
setDialog(ctx, "large", () =>
|
|
331
383
|
ctx.api.ui.DialogSelect({
|
|
332
384
|
title: "Voice microphone",
|
|
@@ -360,15 +412,20 @@ function showMicrophonePicker(ctx) {
|
|
|
360
412
|
function showDiagnostics(ctx) {
|
|
361
413
|
const settings = readSettings(ctx.api.kv);
|
|
362
414
|
const model = getModel(settings.model);
|
|
363
|
-
const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir };
|
|
415
|
+
const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir, skipFfmpegStaticInstall: true };
|
|
364
416
|
const whisperCli = resolveCommand("whisper-cli", commandOptions);
|
|
365
417
|
const ffmpeg = resolveCommand("ffmpeg", commandOptions);
|
|
366
418
|
const arecord = resolveCommand("arecord", commandOptions);
|
|
367
419
|
const sox = resolveCommand("sox", commandOptions);
|
|
368
420
|
const engine = getEngineStatus("whisper.cpp", ctx.options, settings);
|
|
421
|
+
const recorder = getRecorderStatus(commandOptions, settings);
|
|
369
422
|
const lines = [
|
|
370
423
|
`Platform: ${process.platform}-${process.arch}`,
|
|
371
424
|
`Recorder: ffmpeg=${ffmpeg ? "yes" : "no"}${ffmpeg ? ` (${ffmpeg})` : ""}, arecord=${arecord ? "yes" : "no"}, sox=${sox ? "yes" : "no"}`,
|
|
425
|
+
`Recorder source: ${recorder.source}`,
|
|
426
|
+
`Managed recorder dir: ${recorder.managedDir}`,
|
|
427
|
+
`Managed recorder installed: ${recorder.managedInstalled ? "yes" : "no"}`,
|
|
428
|
+
`Managed recorder version: ${recorder.manifest?.version || "missing"}`,
|
|
372
429
|
`Engine: ${engine.id}`,
|
|
373
430
|
`Engine source: ${engine.source}`,
|
|
374
431
|
`whisper-cli: ${whisperCli || "missing"}`,
|
|
@@ -665,8 +722,9 @@ async function startVoice(ctx, submit = false, hold = false) {
|
|
|
665
722
|
|
|
666
723
|
try {
|
|
667
724
|
await ensureEngineReady(ctx, settings);
|
|
725
|
+
await ensureRecorderReady(ctx, settings);
|
|
668
726
|
} catch (error) {
|
|
669
|
-
showError(ctx, "
|
|
727
|
+
showError(ctx, "Voice runtime setup failed", error);
|
|
670
728
|
return;
|
|
671
729
|
}
|
|
672
730
|
|
package/lib/engine.js
CHANGED
|
@@ -4,17 +4,48 @@ import path from "node:path";
|
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { createGunzip } from "node:zlib";
|
|
8
|
+
import { Readable, Transform } from "node:stream";
|
|
9
|
+
import { pipeline } from "node:stream/promises";
|
|
10
|
+
import { ensureDir, replaceFile, sha256 } from "./download.js";
|
|
11
|
+
import { getAudioDir, getCacheDir, getEnginesDir, getModelPath } from "./models.js";
|
|
9
12
|
|
|
10
13
|
const require = createRequire(import.meta.url);
|
|
11
14
|
const FFMPEG_STATIC_PACKAGE = "ffmpeg-static@^5.2.0";
|
|
15
|
+
const FFMPEG_STATIC_RELEASE = "b6.1.1";
|
|
16
|
+
const FFMPEG_STATIC_BASE_URL = "https://github.com/eugeneware/ffmpeg-static/releases/download";
|
|
17
|
+
const DEFAULT_RECORDER_DOWNLOAD_RETRIES = 5;
|
|
12
18
|
const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
19
|
let ffmpegStaticPathCache;
|
|
14
20
|
let ffmpegStaticInstallAttempted = false;
|
|
15
21
|
|
|
16
22
|
const RECORDING_MIN_BYTES = 44;
|
|
17
23
|
|
|
24
|
+
function sleep(ms) {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeArch(value) {
|
|
29
|
+
if (value === "amd64") return "x64";
|
|
30
|
+
if (value === "x86") return "ia32";
|
|
31
|
+
if (value === "aarch64") return "arm64";
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function windowsFfmpegPlatform(options = {}) {
|
|
36
|
+
const platform = options.platform || process.platform;
|
|
37
|
+
const arch = normalizeArch(options.arch || process.arch);
|
|
38
|
+
return { platform, arch, key: `${platform}-${arch}` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isWindows(options = {}) {
|
|
42
|
+
return (options.platform || process.platform) === "win32";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withDownloadDir(options = {}, settings = {}) {
|
|
46
|
+
return { ...options, downloadDir: settings.downloadDir || options.downloadDir };
|
|
47
|
+
}
|
|
48
|
+
|
|
18
49
|
function isExecutable(file) {
|
|
19
50
|
try {
|
|
20
51
|
fs.accessSync(file, fs.constants.X_OK);
|
|
@@ -24,8 +55,8 @@ function isExecutable(file) {
|
|
|
24
55
|
}
|
|
25
56
|
}
|
|
26
57
|
|
|
27
|
-
function executableExtensions() {
|
|
28
|
-
if (
|
|
58
|
+
function executableExtensions(options = {}) {
|
|
59
|
+
if (!isWindows(options)) return [""];
|
|
29
60
|
return (process.env.PATHEXT || ".EXE;.CMD;.BAT")
|
|
30
61
|
.split(";")
|
|
31
62
|
.map((entry) => entry.trim().toLowerCase())
|
|
@@ -33,15 +64,15 @@ function executableExtensions() {
|
|
|
33
64
|
.filter((entry) => entry.startsWith("."));
|
|
34
65
|
}
|
|
35
66
|
|
|
36
|
-
function resolveExecutable(file) {
|
|
67
|
+
function resolveExecutable(file, options = {}) {
|
|
37
68
|
const normalized = path.resolve(file);
|
|
38
69
|
if (isExecutable(normalized)) return normalized;
|
|
39
70
|
|
|
40
|
-
if (path.extname(normalized) ||
|
|
71
|
+
if (path.extname(normalized) || !isWindows(options)) {
|
|
41
72
|
return "";
|
|
42
73
|
}
|
|
43
74
|
|
|
44
|
-
for (const ext of executableExtensions()) {
|
|
75
|
+
for (const ext of executableExtensions(options)) {
|
|
45
76
|
const candidate = `${normalized}${ext}`;
|
|
46
77
|
if (isExecutable(candidate)) return candidate;
|
|
47
78
|
}
|
|
@@ -49,11 +80,329 @@ function resolveExecutable(file) {
|
|
|
49
80
|
return "";
|
|
50
81
|
}
|
|
51
82
|
|
|
52
|
-
function
|
|
83
|
+
function managedFfmpegDir(options = {}, settings = {}) {
|
|
84
|
+
const platform = windowsFfmpegPlatform(options);
|
|
85
|
+
return path.join(getCacheDir(options, settings), "recorders", "ffmpeg-static", platform.key);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getManagedRecorderBinary(options = {}, settings = {}) {
|
|
89
|
+
const platform = windowsFfmpegPlatform(options);
|
|
90
|
+
return path.join(managedFfmpegDir(options, settings), platform.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getManagedRecorderManifestPath(options = {}, settings = {}) {
|
|
94
|
+
return path.join(managedFfmpegDir(options, settings), "manifest.json");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function readManagedRecorderManifest(options = {}, settings = {}) {
|
|
98
|
+
const file = getManagedRecorderManifestPath(options, settings);
|
|
99
|
+
if (!fs.existsSync(file)) return null;
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function temporaryBinaryPath(binaryPath) {
|
|
108
|
+
const extension = path.extname(binaryPath);
|
|
109
|
+
if (!extension) return `${binaryPath}.tmp-${process.pid}`;
|
|
110
|
+
return path.join(path.dirname(binaryPath), `${path.basename(binaryPath, extension)}.tmp-${process.pid}${extension}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function selectManagedRecorderAsset(options = {}) {
|
|
114
|
+
const platform = windowsFfmpegPlatform(options);
|
|
115
|
+
if (platform.platform !== "win32") throw new Error(`Managed ffmpeg recorder is only available for Windows, got ${platform.key}`);
|
|
116
|
+
|
|
117
|
+
const assetArch = platform.arch === "arm64" ? "x64" : platform.arch;
|
|
118
|
+
if (!new Set(["x64", "ia32"]).has(assetArch)) throw new Error(`No managed ffmpeg recorder asset for ${platform.key}`);
|
|
119
|
+
|
|
120
|
+
const assetKey = `${platform.platform}-${assetArch}`;
|
|
121
|
+
return {
|
|
122
|
+
platform,
|
|
123
|
+
assetKey,
|
|
124
|
+
emulated: assetArch !== platform.arch,
|
|
125
|
+
url: `${FFMPEG_STATIC_BASE_URL}/${FFMPEG_STATIC_RELEASE}/ffmpeg-${assetKey}.gz`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function recorderSource(resolved, managedBinary) {
|
|
130
|
+
if (!resolved) return "missing";
|
|
131
|
+
if (path.resolve(resolved) === path.resolve(managedBinary)) return "managed";
|
|
132
|
+
return "system-or-bundled";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function recorderDownloadTimeoutMs(options = {}, hooks = {}) {
|
|
136
|
+
return Number(options.recorderDownloadTimeoutMs || options.downloadTimeoutMs || hooks.timeoutMs || 120000);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function fetchRecorderWithTimeout(url, timeoutMs) {
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
let timer;
|
|
142
|
+
const reset = () => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
145
|
+
};
|
|
146
|
+
const clear = () => clearTimeout(timer);
|
|
147
|
+
reset();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
151
|
+
return { response, signal: controller.signal, reset, clear };
|
|
152
|
+
} catch (error) {
|
|
153
|
+
clear();
|
|
154
|
+
if (error?.name === "AbortError") throw new Error(`Recorder download timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function recorderDownloadError(error, url, timeoutMs) {
|
|
160
|
+
if (error?.name === "AbortError" || error?.code === "ABORT_ERR") {
|
|
161
|
+
return new Error(`Recorder download timed out or stalled from ${url} after ${Math.round(timeoutMs / 1000)}s`);
|
|
162
|
+
}
|
|
163
|
+
return error;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function downloadRecorderAsset(sourceUrl, compressedFile, hooks = {}, attempt = 1, attempts = 1, options = {}) {
|
|
167
|
+
if (sourceUrl.startsWith("file://") || !/^https?:\/\//.test(sourceUrl)) {
|
|
168
|
+
const source = sourceUrl.startsWith("file://") ? new URL(sourceUrl) : path.resolve(sourceUrl);
|
|
169
|
+
const stat = await fs.promises.stat(source);
|
|
170
|
+
let downloaded = 0;
|
|
171
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: stat.size, percent: 0, attempt, attempts });
|
|
172
|
+
const progress = new Transform({
|
|
173
|
+
transform(chunk, _encoding, callback) {
|
|
174
|
+
downloaded += chunk.length ?? chunk.byteLength ?? 0;
|
|
175
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: stat.size, percent: stat.size ? (downloaded / stat.size) * 100 : 0, attempt, attempts });
|
|
176
|
+
callback(null, chunk);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
await pipeline(fs.createReadStream(source), progress, fs.createWriteStream(compressedFile));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const timeoutMs = recorderDownloadTimeoutMs(options, hooks);
|
|
184
|
+
const request = await fetchRecorderWithTimeout(sourceUrl, timeoutMs);
|
|
185
|
+
const response = request.response;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
if (!response.ok) throw new Error(`Recorder download failed from ${sourceUrl}: HTTP ${response.status}`);
|
|
189
|
+
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
190
|
+
let downloaded = 0;
|
|
191
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: contentLength, percent: 0, attempt, attempts });
|
|
192
|
+
|
|
193
|
+
const body = response.body?.getReader ? Readable.fromWeb(response.body) : response.body;
|
|
194
|
+
if (!body) throw new Error("Recorder download failed: empty response body");
|
|
195
|
+
|
|
196
|
+
const progress = new Transform({
|
|
197
|
+
transform(chunk, _encoding, callback) {
|
|
198
|
+
request.reset();
|
|
199
|
+
downloaded += chunk.length ?? chunk.byteLength ?? 0;
|
|
200
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: contentLength, percent: contentLength ? (downloaded / contentLength) * 100 : 0, attempt, attempts });
|
|
201
|
+
callback(null, chunk);
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await pipeline(body, progress, fs.createWriteStream(compressedFile), { signal: request.signal });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw recorderDownloadError(error, sourceUrl, timeoutMs);
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
request.clear();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function probeRecorder(binaryPath, options = {}) {
|
|
216
|
+
if (!binaryPath) return { ok: false, message: "missing binary" };
|
|
217
|
+
|
|
218
|
+
return new Promise((resolve) => {
|
|
219
|
+
let output = "";
|
|
220
|
+
let settled = false;
|
|
221
|
+
let timer;
|
|
222
|
+
const finish = (value) => {
|
|
223
|
+
if (settled) return;
|
|
224
|
+
settled = true;
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
resolve(value);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let proc;
|
|
230
|
+
try {
|
|
231
|
+
proc = spawn(binaryPath, ["-version"], {
|
|
232
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
233
|
+
env: {
|
|
234
|
+
...process.env,
|
|
235
|
+
PATH: [path.dirname(binaryPath), process.env.PATH].filter(Boolean).join(path.delimiter),
|
|
236
|
+
...options.env,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
finish({ ok: false, message: error instanceof Error ? error.message : String(error) });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
timer = setTimeout(() => {
|
|
245
|
+
try {
|
|
246
|
+
proc.kill("SIGKILL");
|
|
247
|
+
} catch {}
|
|
248
|
+
finish({ ok: false, message: "probe timed out" });
|
|
249
|
+
}, options.timeoutMs || 10000);
|
|
250
|
+
|
|
251
|
+
proc.stdout.on("data", (chunk) => {
|
|
252
|
+
output += chunk.toString();
|
|
253
|
+
});
|
|
254
|
+
proc.stderr.on("data", (chunk) => {
|
|
255
|
+
output += chunk.toString();
|
|
256
|
+
});
|
|
257
|
+
proc.on("error", (error) => finish({ ok: false, message: error.message }));
|
|
258
|
+
proc.on("exit", (code) => {
|
|
259
|
+
const versionLine = output.split(/\r?\n/).filter(Boolean)[0] || "";
|
|
260
|
+
const ok = code === 0 && /ffmpeg version/i.test(output);
|
|
261
|
+
finish({ ok, message: ok ? "ok" : `unexpected ffmpeg probe output${typeof code === "number" ? ` (exit ${code})` : ""}`, versionLine });
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function writeManagedRecorderManifest(managedBinary, source, options = {}, settings = {}) {
|
|
267
|
+
const hash = await sha256(managedBinary);
|
|
268
|
+
const stat = await fs.promises.stat(managedBinary);
|
|
269
|
+
const manifest = {
|
|
270
|
+
schema: "opencode-voice.recorder-install.v1",
|
|
271
|
+
id: "ffmpeg",
|
|
272
|
+
kind: "cli",
|
|
273
|
+
command: "ffmpeg",
|
|
274
|
+
platform: windowsFfmpegPlatform(options).key,
|
|
275
|
+
version: FFMPEG_STATIC_RELEASE,
|
|
276
|
+
source,
|
|
277
|
+
files: [{ path: path.basename(managedBinary), sha256: hash, size: stat.size }],
|
|
278
|
+
installedAt: new Date().toISOString(),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
await fs.promises.writeFile(getManagedRecorderManifestPath(options, settings), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
282
|
+
return manifest;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function getRecorderStatus(options = {}, settings = {}) {
|
|
286
|
+
const commandOptions = withDownloadDir(options, settings);
|
|
287
|
+
const managedDir = managedFfmpegDir(options, settings);
|
|
288
|
+
const managedBinary = getManagedRecorderBinary(options, settings);
|
|
289
|
+
const manifest = readManagedRecorderManifest(options, settings);
|
|
290
|
+
const resolvedBinary = resolveCommand("ffmpeg", commandOptions);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
id: "ffmpeg",
|
|
294
|
+
command: "ffmpeg",
|
|
295
|
+
platform: windowsFfmpegPlatform(options).key,
|
|
296
|
+
supported: isWindows(options),
|
|
297
|
+
managedDir,
|
|
298
|
+
managedBinary,
|
|
299
|
+
managedInstalled: isExecutable(managedBinary),
|
|
300
|
+
manifest,
|
|
301
|
+
resolvedBinary,
|
|
302
|
+
source: recorderSource(resolvedBinary, managedBinary),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function installManagedRecorder(options = {}, settings = {}, hooks = {}) {
|
|
307
|
+
const commandOptions = withDownloadDir(options, settings);
|
|
308
|
+
const asset = selectManagedRecorderAsset(commandOptions);
|
|
309
|
+
const managedDir = managedFfmpegDir(commandOptions, settings);
|
|
310
|
+
const managedBinary = getManagedRecorderBinary(commandOptions, settings);
|
|
311
|
+
if (!hooks.force && fs.existsSync(managedBinary)) {
|
|
312
|
+
const probe = await probeRecorder(managedBinary);
|
|
313
|
+
if (probe.ok) return { manifest: readManagedRecorderManifest(commandOptions, settings), managedBinary, probe, skipped: true };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await ensureDir(managedDir);
|
|
317
|
+
const compressedFile = path.join(managedDir, `${path.basename(asset.url)}.download`);
|
|
318
|
+
const tmpBinary = temporaryBinaryPath(managedBinary);
|
|
319
|
+
const attempts = Number(options.recorderDownloadRetries || hooks.retries || DEFAULT_RECORDER_DOWNLOAD_RETRIES);
|
|
320
|
+
let lastError;
|
|
321
|
+
|
|
322
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
323
|
+
try {
|
|
324
|
+
await fs.promises.unlink(compressedFile).catch(() => {});
|
|
325
|
+
await fs.promises.unlink(tmpBinary).catch(() => {});
|
|
326
|
+
await downloadRecorderAsset(asset.url, compressedFile, hooks, attempt, attempts, commandOptions);
|
|
327
|
+
|
|
328
|
+
const compressedSize = fs.existsSync(compressedFile) ? fs.statSync(compressedFile).size : 0;
|
|
329
|
+
hooks.onProgress?.({ state: "decompressing", downloaded: compressedSize, total: compressedSize, percent: 100, attempt, attempts });
|
|
330
|
+
await pipeline(fs.createReadStream(compressedFile), createGunzip(), fs.createWriteStream(tmpBinary));
|
|
331
|
+
await fs.promises.chmod(tmpBinary, 0o755);
|
|
332
|
+
|
|
333
|
+
hooks.onProgress?.({ state: "probing", downloaded: fs.statSync(tmpBinary).size, total: fs.statSync(tmpBinary).size, percent: 100, attempt, attempts });
|
|
334
|
+
const probe = await probeRecorder(tmpBinary);
|
|
335
|
+
if (!probe.ok) throw new Error(`Recorder probe failed: ${probe.message}`);
|
|
336
|
+
|
|
337
|
+
await replaceFile(tmpBinary, managedBinary);
|
|
338
|
+
await fs.promises.unlink(compressedFile).catch(() => {});
|
|
339
|
+
const manifest = await writeManagedRecorderManifest(
|
|
340
|
+
managedBinary,
|
|
341
|
+
{
|
|
342
|
+
type: "ffmpeg-static-release",
|
|
343
|
+
release: FFMPEG_STATIC_RELEASE,
|
|
344
|
+
url: asset.url,
|
|
345
|
+
assetPlatform: asset.assetKey,
|
|
346
|
+
emulated: asset.emulated,
|
|
347
|
+
},
|
|
348
|
+
commandOptions,
|
|
349
|
+
settings,
|
|
350
|
+
);
|
|
351
|
+
const installedSize = fs.statSync(managedBinary).size;
|
|
352
|
+
hooks.onProgress?.({ state: "done", downloaded: installedSize, total: installedSize, percent: 100, attempt, attempts });
|
|
353
|
+
return { manifest, managedBinary, probe, skipped: false };
|
|
354
|
+
} catch (error) {
|
|
355
|
+
lastError = error;
|
|
356
|
+
await fs.promises.unlink(tmpBinary).catch(() => {});
|
|
357
|
+
if (attempt >= attempts) break;
|
|
358
|
+
hooks.onRetry?.({ error, attempt, attempts, nextAttempt: attempt + 1 });
|
|
359
|
+
await sleep(Math.min(1000 * attempt, 3000));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
throw lastError;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function ensureManagedRecorder(options = {}, settings = {}, hooks = {}) {
|
|
367
|
+
const commandOptions = withDownloadDir(options, settings);
|
|
368
|
+
if (!isWindows(commandOptions)) return { ...getRecorderStatus(commandOptions, settings), ok: true, skipped: true, unsupported: true };
|
|
369
|
+
|
|
370
|
+
const existingOptions = { ...commandOptions, skipFfmpegStaticInstall: true };
|
|
371
|
+
const current = getRecorderStatus(existingOptions, settings);
|
|
372
|
+
if (current.resolvedBinary) {
|
|
373
|
+
const probe = await probeRecorder(current.resolvedBinary);
|
|
374
|
+
if (probe.ok) return { ...current, ok: true, probe, skipped: true };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const installed = await installManagedRecorder(commandOptions, settings, hooks);
|
|
379
|
+
return { ...getRecorderStatus(commandOptions, settings), ...installed, ok: true };
|
|
380
|
+
} catch (error) {
|
|
381
|
+
const resolvedBinary = resolveRecorderFallback(existingOptions, settings);
|
|
382
|
+
if (resolvedBinary) {
|
|
383
|
+
const probe = await probeRecorder(resolvedBinary);
|
|
384
|
+
if (probe.ok) {
|
|
385
|
+
return {
|
|
386
|
+
...getRecorderStatus(commandOptions, settings),
|
|
387
|
+
resolvedBinary,
|
|
388
|
+
source: recorderSource(resolvedBinary, getManagedRecorderBinary(commandOptions, settings)),
|
|
389
|
+
ok: true,
|
|
390
|
+
fallback: true,
|
|
391
|
+
managedError: error instanceof Error ? error.message : String(error),
|
|
392
|
+
probe,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
throw new Error(`Windows ffmpeg recorder install failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function executableNames(command, options = {}) {
|
|
53
402
|
const names = [command];
|
|
54
403
|
if (path.extname(command)) return names;
|
|
55
404
|
|
|
56
|
-
if (
|
|
405
|
+
if (isWindows(options)) {
|
|
57
406
|
const extensions = (process.env.PATHEXT || ".EXE;.CMD;.BAT")
|
|
58
407
|
.split(";")
|
|
59
408
|
.map((item) => item.trim().toLowerCase())
|
|
@@ -64,7 +413,7 @@ function executableNames(command) {
|
|
|
64
413
|
return [...new Set(names)];
|
|
65
414
|
}
|
|
66
415
|
|
|
67
|
-
function bundledFfmpegPath() {
|
|
416
|
+
function bundledFfmpegPath(options = {}) {
|
|
68
417
|
if (process.platform !== "win32") return "";
|
|
69
418
|
if (ffmpegStaticPathCache !== undefined) return ffmpegStaticPathCache;
|
|
70
419
|
|
|
@@ -98,7 +447,7 @@ function bundledFfmpegPath() {
|
|
|
98
447
|
|
|
99
448
|
let candidate = resolveFromDependency();
|
|
100
449
|
if (!candidate) candidate = resolveFromLocalModule();
|
|
101
|
-
if (!candidate && !ffmpegStaticInstallAttempted) {
|
|
450
|
+
if (!candidate && !ffmpegStaticInstallAttempted && !options.skipFfmpegStaticInstall) {
|
|
102
451
|
ffmpegStaticInstallAttempted = true;
|
|
103
452
|
if (installDependency()) {
|
|
104
453
|
candidate = resolveFromDependency();
|
|
@@ -106,6 +455,7 @@ function bundledFfmpegPath() {
|
|
|
106
455
|
}
|
|
107
456
|
|
|
108
457
|
if (!candidate) candidate = resolveFromLocalModule();
|
|
458
|
+
if (!candidate && options.skipFfmpegStaticInstall) return "";
|
|
109
459
|
|
|
110
460
|
ffmpegStaticPathCache = candidate || "";
|
|
111
461
|
return ffmpegStaticPathCache;
|
|
@@ -133,16 +483,19 @@ function candidateCommands(command, options = {}) {
|
|
|
133
483
|
}
|
|
134
484
|
|
|
135
485
|
if (command === "ffmpeg") {
|
|
136
|
-
|
|
486
|
+
if (options.ffmpeg) candidates.push(options.ffmpeg);
|
|
487
|
+
if (process.env.OPENCODE_VOICE_FFMPEG) candidates.push(process.env.OPENCODE_VOICE_FFMPEG);
|
|
488
|
+
if (isWindows(options)) candidates.push(getManagedRecorderBinary(options, options));
|
|
489
|
+
const bundled = bundledFfmpegPath(options);
|
|
137
490
|
if (bundled) candidates.push(bundled);
|
|
138
491
|
}
|
|
139
492
|
|
|
140
493
|
const bundledDir = getBundledEngineDir(command, options);
|
|
141
494
|
if (bundledDir) {
|
|
142
|
-
for (const name of executableNames(command)) candidates.push(path.join(bundledDir, name));
|
|
495
|
+
for (const name of executableNames(command, options)) candidates.push(path.join(bundledDir, name));
|
|
143
496
|
}
|
|
144
497
|
|
|
145
|
-
candidates.push(...executableNames(command));
|
|
498
|
+
candidates.push(...executableNames(command, options));
|
|
146
499
|
|
|
147
500
|
const fallbackDirs = [
|
|
148
501
|
path.join(os.homedir(), ".local", "bin"),
|
|
@@ -153,13 +506,13 @@ function candidateCommands(command, options = {}) {
|
|
|
153
506
|
"/bin",
|
|
154
507
|
];
|
|
155
508
|
|
|
156
|
-
if (
|
|
509
|
+
if (isWindows(options)) {
|
|
157
510
|
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
158
511
|
fallbackDirs.unshift(path.join(localAppData, "opencode-voice", "bin"));
|
|
159
512
|
}
|
|
160
513
|
|
|
161
514
|
for (const dir of fallbackDirs) {
|
|
162
|
-
for (const name of executableNames(command)) candidates.push(path.join(dir, name));
|
|
515
|
+
for (const name of executableNames(command, options)) candidates.push(path.join(dir, name));
|
|
163
516
|
}
|
|
164
517
|
|
|
165
518
|
return [...new Set(candidates.filter(Boolean))];
|
|
@@ -168,14 +521,34 @@ function candidateCommands(command, options = {}) {
|
|
|
168
521
|
export function resolveCommand(command, options = {}) {
|
|
169
522
|
for (const candidate of candidateCommands(command, options)) {
|
|
170
523
|
if (looksLikePath(candidate)) {
|
|
171
|
-
const resolved = resolveExecutable(candidate);
|
|
524
|
+
const resolved = resolveExecutable(candidate, options);
|
|
172
525
|
if (resolved) return resolved;
|
|
173
526
|
continue;
|
|
174
527
|
}
|
|
175
528
|
|
|
176
529
|
for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
177
530
|
const file = path.join(dir, candidate);
|
|
178
|
-
|
|
531
|
+
const resolved = resolveExecutable(file, options);
|
|
532
|
+
if (resolved) return resolved;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function resolveRecorderFallback(options = {}, settings = {}) {
|
|
540
|
+
const managedBinary = path.resolve(getManagedRecorderBinary(options, settings));
|
|
541
|
+
for (const candidate of candidateCommands("ffmpeg", options)) {
|
|
542
|
+
if (looksLikePath(candidate)) {
|
|
543
|
+
const resolved = resolveExecutable(candidate, options);
|
|
544
|
+
if (resolved && path.resolve(resolved) !== managedBinary) return resolved;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
549
|
+
const file = path.join(dir, candidate);
|
|
550
|
+
const resolved = resolveExecutable(file, options);
|
|
551
|
+
if (resolved && path.resolve(resolved) !== managedBinary) return resolved;
|
|
179
552
|
}
|
|
180
553
|
}
|
|
181
554
|
|
|
@@ -216,9 +589,9 @@ function parseWindowsMicrophones(stderr) {
|
|
|
216
589
|
return [...devices].filter(Boolean);
|
|
217
590
|
}
|
|
218
591
|
|
|
219
|
-
export function listMicrophones() {
|
|
220
|
-
const arecordCommand = resolveCommand("arecord");
|
|
221
|
-
const ffmpegCommand = resolveCommand("ffmpeg");
|
|
592
|
+
export function listMicrophones(options = {}) {
|
|
593
|
+
const arecordCommand = resolveCommand("arecord", options);
|
|
594
|
+
const ffmpegCommand = resolveCommand("ffmpeg", options);
|
|
222
595
|
|
|
223
596
|
if (process.platform === "linux" && arecordCommand) {
|
|
224
597
|
const result = spawnSync(arecordCommand, ["-L"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -416,9 +789,15 @@ export class VoiceRuntime {
|
|
|
416
789
|
|
|
417
790
|
const dir = getAudioDir(this.options, settings);
|
|
418
791
|
await ensureDir(dir);
|
|
792
|
+
const recorderSettings = { ...this.options, ...settings, downloadDir: settings.downloadDir || this.options.downloadDir };
|
|
793
|
+
const recorderReady = await ensureManagedRecorder(recorderSettings, settings).catch((error) => {
|
|
794
|
+
if (isWindows(recorderSettings)) throw error;
|
|
795
|
+
return null;
|
|
796
|
+
});
|
|
797
|
+
if (recorderReady?.resolvedBinary) recorderSettings.ffmpeg = recorderReady.resolvedBinary;
|
|
419
798
|
|
|
420
799
|
const file = path.join(dir, `voice-${Date.now()}.wav`);
|
|
421
|
-
const recorders = buildRecorders(file,
|
|
800
|
+
const recorders = buildRecorders(file, recorderSettings);
|
|
422
801
|
const errors = [];
|
|
423
802
|
this.recordingError = "";
|
|
424
803
|
|
package/lib/engines.js
CHANGED
|
@@ -9,7 +9,7 @@ import { sha256, ensureDir, replaceFile } from "./download.js";
|
|
|
9
9
|
import { getCacheDir } from "./models.js";
|
|
10
10
|
import { getBundledEngineDir, resolveCommand } from "./engine.js";
|
|
11
11
|
|
|
12
|
-
export const DEFAULT_ENGINE_REGISTRY_URL = "https://github.com/ihxnnxs/opencode-voice/releases/download/v0.1.
|
|
12
|
+
export const DEFAULT_ENGINE_REGISTRY_URL = "https://github.com/ihxnnxs/opencode-voice/releases/download/v0.1.9/registry.json";
|
|
13
13
|
|
|
14
14
|
const ENGINE = {
|
|
15
15
|
id: "whisper.cpp",
|
|
@@ -19,13 +19,14 @@ const ENGINE = {
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
const DEFAULT_ENGINE_DOWNLOAD_RETRIES = 5;
|
|
22
|
+
const DEFAULT_ENGINE_DOWNLOAD_TIMEOUT_MS = 120000;
|
|
22
23
|
|
|
23
24
|
function sleep(ms) {
|
|
24
25
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function executableName() {
|
|
28
|
-
return process.platform === "win32" ? "whisper-cli.exe" : "whisper-cli";
|
|
28
|
+
function executableName(options = {}) {
|
|
29
|
+
return (options.platform || process.platform) === "win32" ? "whisper-cli.exe" : "whisper-cli";
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function temporaryBinaryPath(binaryPath) {
|
|
@@ -45,13 +46,17 @@ export function getEnginePlatformKey(options = {}) {
|
|
|
45
46
|
return `${options.platform || process.platform}-${normalizePlatform(options.arch || process.arch)}`;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
function withDownloadDir(options = {}, settings = {}) {
|
|
50
|
+
return { ...options, downloadDir: settings.downloadDir || options.downloadDir };
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
export function getManagedEngineDir(engineId = ENGINE.id, options = {}, settings = {}) {
|
|
49
54
|
if (engineId !== ENGINE.id) throw new Error(`Unsupported engine: ${engineId}`);
|
|
50
|
-
return getBundledEngineDir(ENGINE.command,
|
|
55
|
+
return getBundledEngineDir(ENGINE.command, withDownloadDir(options, settings));
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
export function getManagedEngineBinary(engineId = ENGINE.id, options = {}, settings = {}) {
|
|
54
|
-
return path.join(getManagedEngineDir(engineId, options, settings), executableName());
|
|
59
|
+
return path.join(getManagedEngineDir(engineId, options, settings), executableName(options));
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
export function getEngineManifestPath(engineId = ENGINE.id, options = {}, settings = {}) {
|
|
@@ -77,32 +82,52 @@ function engineSource(resolved, managedBinary, options = {}) {
|
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
export function getEngineStatus(engineId = ENGINE.id, options = {}, settings = {}) {
|
|
80
|
-
const commandOptions =
|
|
81
|
-
const managedDir = getManagedEngineDir(engineId,
|
|
82
|
-
const managedBinary = getManagedEngineBinary(engineId,
|
|
83
|
-
const manifest = readEngineManifest(engineId,
|
|
85
|
+
const commandOptions = withDownloadDir(options, settings);
|
|
86
|
+
const managedDir = getManagedEngineDir(engineId, commandOptions);
|
|
87
|
+
const managedBinary = getManagedEngineBinary(engineId, commandOptions);
|
|
88
|
+
const manifest = readEngineManifest(engineId, commandOptions);
|
|
84
89
|
const resolvedBinary = resolveCommand(ENGINE.command, commandOptions);
|
|
85
90
|
|
|
86
91
|
return {
|
|
87
92
|
id: engineId,
|
|
88
93
|
command: ENGINE.command,
|
|
89
|
-
platform: getEnginePlatformKey(
|
|
90
|
-
cacheDir: getCacheDir(
|
|
94
|
+
platform: getEnginePlatformKey(commandOptions),
|
|
95
|
+
cacheDir: getCacheDir(commandOptions),
|
|
91
96
|
managedDir,
|
|
92
97
|
managedBinary,
|
|
93
98
|
managedInstalled: fs.existsSync(managedBinary),
|
|
94
99
|
manifest,
|
|
95
100
|
resolvedBinary,
|
|
96
|
-
source: engineSource(resolvedBinary, managedBinary,
|
|
101
|
+
source: engineSource(resolvedBinary, managedBinary, commandOptions),
|
|
97
102
|
};
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
function summarizeProbeOutput(output) {
|
|
106
|
+
return String(output || "").replace(/\s+/g, " ").trim().slice(0, 240);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function engineProbeMessage(message) {
|
|
110
|
+
const text = summarizeProbeOutput(message);
|
|
111
|
+
if (/\b(MSVCP140|VCOMP140|VCRUNTIME140|VCRUNTIME140_1|api-ms-win-crt)\b/i.test(text)) {
|
|
112
|
+
return `${text}; Windows VC++ runtime dependency is missing`;
|
|
113
|
+
}
|
|
114
|
+
return text || "unknown probe failure";
|
|
115
|
+
}
|
|
116
|
+
|
|
100
117
|
export async function probeEngine(engineId = ENGINE.id, binaryPath, options = {}) {
|
|
101
118
|
if (engineId !== ENGINE.id) throw new Error(`Unsupported engine: ${engineId}`);
|
|
102
119
|
if (!binaryPath) return { ok: false, message: "missing binary" };
|
|
103
120
|
|
|
104
121
|
return new Promise((resolve) => {
|
|
105
122
|
let output = "";
|
|
123
|
+
let settled = false;
|
|
124
|
+
let timer;
|
|
125
|
+
const finish = (result) => {
|
|
126
|
+
if (settled) return;
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
resolve(result);
|
|
130
|
+
};
|
|
106
131
|
const localLib = path.join(os.homedir(), ".local", "lib");
|
|
107
132
|
const env = {
|
|
108
133
|
...process.env,
|
|
@@ -112,11 +137,11 @@ export async function probeEngine(engineId = ENGINE.id, binaryPath, options = {}
|
|
|
112
137
|
...options.env,
|
|
113
138
|
};
|
|
114
139
|
const proc = spawn(binaryPath, ENGINE.probeArgs, { stdio: ["ignore", "pipe", "pipe"], env });
|
|
115
|
-
|
|
140
|
+
timer = setTimeout(() => {
|
|
116
141
|
try {
|
|
117
142
|
proc.kill("SIGKILL");
|
|
118
143
|
} catch {}
|
|
119
|
-
|
|
144
|
+
finish({ ok: false, message: "probe timed out" });
|
|
120
145
|
}, options.timeoutMs || 10000);
|
|
121
146
|
|
|
122
147
|
proc.stdout.on("data", (chunk) => {
|
|
@@ -126,13 +151,12 @@ export async function probeEngine(engineId = ENGINE.id, binaryPath, options = {}
|
|
|
126
151
|
output += chunk.toString();
|
|
127
152
|
});
|
|
128
153
|
proc.on("error", (error) => {
|
|
129
|
-
|
|
130
|
-
resolve({ ok: false, message: error.message });
|
|
154
|
+
finish({ ok: false, message: engineProbeMessage(error.message) });
|
|
131
155
|
});
|
|
132
|
-
proc.on("exit", () => {
|
|
133
|
-
clearTimeout(timer);
|
|
156
|
+
proc.on("exit", (code, signal) => {
|
|
134
157
|
const ok = ENGINE.probeContains.every((item) => output.includes(item));
|
|
135
|
-
|
|
158
|
+
const detail = summarizeProbeOutput(output) || `exit ${code ?? signal ?? "unknown"}`;
|
|
159
|
+
finish({ ok, message: ok ? "ok" : engineProbeMessage(`unexpected whisper-cli help output: ${detail}`) });
|
|
136
160
|
});
|
|
137
161
|
});
|
|
138
162
|
}
|
|
@@ -150,6 +174,14 @@ async function fetchWithTimeout(url, init = {}, timeoutMs = 60000) {
|
|
|
150
174
|
}
|
|
151
175
|
}
|
|
152
176
|
|
|
177
|
+
function engineDownloadTimeoutMs(options = {}, hooks = {}) {
|
|
178
|
+
return Number(options.engineDownloadTimeoutMs || options.downloadTimeoutMs || hooks.timeoutMs || DEFAULT_ENGINE_DOWNLOAD_TIMEOUT_MS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function engineDownloadTimeoutError(url, timeoutMs) {
|
|
182
|
+
return new Error(`Engine download stalled after ${Math.round(timeoutMs / 1000)}s: ${url}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
153
185
|
async function readJsonUrl(url, options = {}) {
|
|
154
186
|
if (url.startsWith("file://")) return JSON.parse(await fs.promises.readFile(new URL(url), "utf8"));
|
|
155
187
|
if (!/^https?:\/\//.test(url)) return JSON.parse(await fs.promises.readFile(path.resolve(url), "utf8"));
|
|
@@ -201,7 +233,8 @@ async function downloadAsset(asset, compressedFile, hooks = {}, attempt = 1, att
|
|
|
201
233
|
return;
|
|
202
234
|
}
|
|
203
235
|
|
|
204
|
-
const
|
|
236
|
+
const timeoutMs = engineDownloadTimeoutMs(options, hooks);
|
|
237
|
+
const response = await fetchWithTimeout(asset.url, {}, timeoutMs);
|
|
205
238
|
if (!response.ok) throw new Error(`Engine download failed: HTTP ${response.status}`);
|
|
206
239
|
|
|
207
240
|
const contentLength = Number(response.headers.get("content-length") || asset.size || 0);
|
|
@@ -211,15 +244,31 @@ async function downloadAsset(asset, compressedFile, hooks = {}, attempt = 1, att
|
|
|
211
244
|
const body = response.body?.getReader ? Readable.fromWeb(response.body) : response.body;
|
|
212
245
|
if (!body) throw new Error("Engine download failed: empty response body");
|
|
213
246
|
|
|
247
|
+
const controller = new AbortController();
|
|
248
|
+
let stallTimer;
|
|
249
|
+
const resetStallTimer = () => {
|
|
250
|
+
clearTimeout(stallTimer);
|
|
251
|
+
stallTimer = setTimeout(() => controller.abort(), timeoutMs);
|
|
252
|
+
};
|
|
253
|
+
|
|
214
254
|
const progress = new Transform({
|
|
215
255
|
transform(chunk, _encoding, callback) {
|
|
256
|
+
resetStallTimer();
|
|
216
257
|
downloaded += chunk.length ?? chunk.byteLength ?? 0;
|
|
217
258
|
hooks.onProgress?.({ state: "downloading", downloaded, total: contentLength, percent: contentLength ? (downloaded / contentLength) * 100 : 0, attempt, attempts });
|
|
218
259
|
callback(null, chunk);
|
|
219
260
|
},
|
|
220
261
|
});
|
|
221
262
|
|
|
222
|
-
|
|
263
|
+
resetStallTimer();
|
|
264
|
+
try {
|
|
265
|
+
await pipeline(body, progress, fs.createWriteStream(compressedFile), { signal: controller.signal });
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error?.name === "AbortError") throw engineDownloadTimeoutError(asset.url, timeoutMs);
|
|
268
|
+
throw error;
|
|
269
|
+
} finally {
|
|
270
|
+
clearTimeout(stallTimer);
|
|
271
|
+
}
|
|
223
272
|
}
|
|
224
273
|
|
|
225
274
|
async function writeManifest(engineId, managedBinary, source, options = {}) {
|
|
@@ -244,29 +293,30 @@ async function writeManifest(engineId, managedBinary, source, options = {}) {
|
|
|
244
293
|
export async function installManagedEngine(engineId = ENGINE.id, options = {}, settings = {}, hooks = {}) {
|
|
245
294
|
if (engineId !== ENGINE.id) throw new Error(`Unsupported engine: ${engineId}`);
|
|
246
295
|
|
|
247
|
-
const
|
|
248
|
-
const
|
|
296
|
+
const commandOptions = withDownloadDir(options, settings);
|
|
297
|
+
const managedDir = getManagedEngineDir(engineId, commandOptions);
|
|
298
|
+
const managedBinary = getManagedEngineBinary(engineId, commandOptions);
|
|
249
299
|
if (!hooks.force && fs.existsSync(managedBinary)) {
|
|
250
300
|
const probe = await probeEngine(engineId, managedBinary);
|
|
251
|
-
if (probe.ok) return { manifest: readEngineManifest(engineId,
|
|
301
|
+
if (probe.ok) return { manifest: readEngineManifest(engineId, commandOptions), managedBinary, skipped: true };
|
|
252
302
|
}
|
|
253
303
|
|
|
254
304
|
hooks.onProgress?.({ state: "registry", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts: 1 });
|
|
255
|
-
const { url: registryUrl, registry } = await loadEngineRegistry(
|
|
256
|
-
const platform = getEnginePlatformKey(
|
|
305
|
+
const { url: registryUrl, registry } = await loadEngineRegistry(commandOptions);
|
|
306
|
+
const platform = getEnginePlatformKey(commandOptions);
|
|
257
307
|
const { engine, asset } = selectEngineAsset(registry, engineId, platform);
|
|
258
308
|
|
|
259
309
|
await ensureDir(managedDir);
|
|
260
310
|
const compressedFile = path.join(managedDir, `${path.basename(asset.url || `${platform}.gz`)}.download`);
|
|
261
311
|
const tmpBinary = temporaryBinaryPath(managedBinary);
|
|
262
|
-
const attempts = Number(
|
|
312
|
+
const attempts = Number(commandOptions.engineDownloadRetries || hooks.retries || DEFAULT_ENGINE_DOWNLOAD_RETRIES);
|
|
263
313
|
let lastError;
|
|
264
314
|
|
|
265
315
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
266
316
|
try {
|
|
267
317
|
await fs.promises.unlink(compressedFile).catch(() => {});
|
|
268
318
|
await fs.promises.unlink(tmpBinary).catch(() => {});
|
|
269
|
-
await downloadAsset(asset, compressedFile, hooks, attempt, attempts,
|
|
319
|
+
await downloadAsset(asset, compressedFile, hooks, attempt, attempts, commandOptions);
|
|
270
320
|
|
|
271
321
|
hooks.onProgress?.({ state: "verifying", downloaded: asset.size || 0, total: asset.size || 0, percent: 100, attempt, attempts });
|
|
272
322
|
if (asset.sha256 && (await sha256(compressedFile)) !== asset.sha256) throw new Error("Engine archive SHA256 mismatch");
|
|
@@ -282,8 +332,7 @@ export async function installManagedEngine(engineId = ENGINE.id, options = {}, s
|
|
|
282
332
|
const probe = await probeEngine(engineId, tmpBinary);
|
|
283
333
|
if (!probe.ok) throw new Error(`Engine probe failed: ${probe.message}`);
|
|
284
334
|
|
|
285
|
-
await
|
|
286
|
-
await fs.promises.rename(tmpBinary, managedBinary);
|
|
335
|
+
await replaceFile(tmpBinary, managedBinary);
|
|
287
336
|
await fs.promises.unlink(compressedFile).catch(() => {});
|
|
288
337
|
const manifest = await writeManifest(
|
|
289
338
|
engineId,
|
|
@@ -295,7 +344,7 @@ export async function installManagedEngine(engineId = ENGINE.id, options = {}, s
|
|
|
295
344
|
version: engine.version || registry.version || "registry",
|
|
296
345
|
upstream: engine.upstream,
|
|
297
346
|
},
|
|
298
|
-
|
|
347
|
+
commandOptions,
|
|
299
348
|
);
|
|
300
349
|
hooks.onProgress?.({ state: "done", downloaded: asset.binary?.size || asset.size || 0, total: asset.binary?.size || asset.size || 0, percent: 100, attempt, attempts });
|
|
301
350
|
return { manifest, managedBinary, skipped: false };
|
|
@@ -319,8 +368,9 @@ export async function importManagedEngine(engineId = ENGINE.id, sourcePath, opti
|
|
|
319
368
|
const stat = await fs.promises.stat(source).catch(() => null);
|
|
320
369
|
if (!stat?.isFile()) throw new Error(`Engine binary not found: ${source}`);
|
|
321
370
|
|
|
322
|
-
const
|
|
323
|
-
const
|
|
371
|
+
const commandOptions = withDownloadDir(options, settings);
|
|
372
|
+
const managedDir = getManagedEngineDir(engineId, commandOptions);
|
|
373
|
+
const managedBinary = getManagedEngineBinary(engineId, commandOptions);
|
|
324
374
|
const tmp = temporaryBinaryPath(managedBinary);
|
|
325
375
|
await ensureDir(managedDir);
|
|
326
376
|
await fs.promises.copyFile(source, tmp);
|
|
@@ -332,9 +382,8 @@ export async function importManagedEngine(engineId = ENGINE.id, sourcePath, opti
|
|
|
332
382
|
throw new Error(`Imported binary failed probe: ${probe.message}`);
|
|
333
383
|
}
|
|
334
384
|
|
|
335
|
-
await
|
|
336
|
-
await
|
|
337
|
-
const manifest = await writeManifest(engineId, managedBinary, { type: "local-file", path: source }, options);
|
|
385
|
+
await replaceFile(tmp, managedBinary);
|
|
386
|
+
const manifest = await writeManifest(engineId, managedBinary, { type: "local-file", path: source }, commandOptions);
|
|
338
387
|
return { manifest, managedBinary };
|
|
339
388
|
}
|
|
340
389
|
|