@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 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` with DirectShow (system or bundled `ffmpeg` fallback) |
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
 
@@ -67,21 +67,41 @@ async function runtime() {
67
67
 
68
68
  async function doctor() {
69
69
  const {
70
- commandExists,
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 ffmpeg = resolveCommand("ffmpeg");
83
- const arecord = resolveCommand("arecord");
84
- const sox = resolveCommand("sox");
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: commandExists("ffmpeg"),
97
- arecordPresent: commandExists("arecord"),
98
- soxPresent: commandExists("sox"),
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 (hasFlag("--json")) {
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` + DirectShow (desde ffmpeg del sistema o fallback incluido) |
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` + DirectShow (через системный ffmpeg или встроенный fallback) |
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 | 下载 engine/model 一条命令完成;通过 `ffmpeg` + DirectShow 录音(使用系统 ffmpeg 或内置备用) |
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 选择和 `whisper-cli` 转写
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, commandExists, listMicrophones, resolveCommand } from "./lib/engine.js";
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, "Engine install failed", error);
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 { ensureDir } from "./download.js";
8
- import { getAudioDir, getEnginesDir, getModelPath } from "./models.js";
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 (process.platform !== "win32") return [""];
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) || process.platform !== "win32") {
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 executableNames(command) {
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 (process.platform === "win32") {
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
- const bundled = bundledFfmpegPath();
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 (process.platform === "win32") {
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
- if (isExecutable(file)) return file;
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, settings);
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.0/registry.json";
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, { ...options, downloadDir: settings.downloadDir });
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 = { ...options, downloadDir: settings.downloadDir };
81
- const managedDir = getManagedEngineDir(engineId, options, settings);
82
- const managedBinary = getManagedEngineBinary(engineId, options, settings);
83
- const manifest = readEngineManifest(engineId, options, settings);
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(options),
90
- cacheDir: getCacheDir(options, settings),
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, options),
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
- const timer = setTimeout(() => {
140
+ timer = setTimeout(() => {
116
141
  try {
117
142
  proc.kill("SIGKILL");
118
143
  } catch {}
119
- resolve({ ok: false, message: "probe timed out" });
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
- clearTimeout(timer);
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
- resolve({ ok, message: ok ? "ok" : "unexpected whisper-cli help output" });
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 response = await fetchWithTimeout(asset.url, {}, Number(options.downloadTimeoutMs || hooks.timeoutMs || 120000));
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
- await pipeline(body, progress, fs.createWriteStream(compressedFile));
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 managedDir = getManagedEngineDir(engineId, options, settings);
248
- const managedBinary = getManagedEngineBinary(engineId, options, settings);
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, options, settings), managedBinary, skipped: true };
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(options);
256
- const platform = getEnginePlatformKey(options);
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(options.engineDownloadRetries || hooks.retries || DEFAULT_ENGINE_DOWNLOAD_RETRIES);
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, options);
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 fs.promises.unlink(managedBinary).catch(() => {});
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
- options,
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 managedDir = getManagedEngineDir(engineId, options, settings);
323
- const managedBinary = getManagedEngineBinary(engineId, options, settings);
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 fs.promises.unlink(managedBinary).catch(() => {});
336
- await fs.promises.rename(tmp, managedBinary);
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hxnnxs/opencode-voice",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Local voice input plugin for OpenCode",
5
5
  "type": "module",
6
6
  "license": "MIT",