@hxnnxs/opencode-voice 0.1.8 → 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 +8 -0
- package/lib/engines.js +83 -32
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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
|
+
|
|
5
13
|
## 0.1.8 - 2026-06-21
|
|
6
14
|
|
|
7
15
|
### Fixed
|
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");
|
|
@@ -294,7 +344,7 @@ export async function installManagedEngine(engineId = ENGINE.id, options = {}, s
|
|
|
294
344
|
version: engine.version || registry.version || "registry",
|
|
295
345
|
upstream: engine.upstream,
|
|
296
346
|
},
|
|
297
|
-
|
|
347
|
+
commandOptions,
|
|
298
348
|
);
|
|
299
349
|
hooks.onProgress?.({ state: "done", downloaded: asset.binary?.size || asset.size || 0, total: asset.binary?.size || asset.size || 0, percent: 100, attempt, attempts });
|
|
300
350
|
return { manifest, managedBinary, skipped: false };
|
|
@@ -318,8 +368,9 @@ export async function importManagedEngine(engineId = ENGINE.id, sourcePath, opti
|
|
|
318
368
|
const stat = await fs.promises.stat(source).catch(() => null);
|
|
319
369
|
if (!stat?.isFile()) throw new Error(`Engine binary not found: ${source}`);
|
|
320
370
|
|
|
321
|
-
const
|
|
322
|
-
const
|
|
371
|
+
const commandOptions = withDownloadDir(options, settings);
|
|
372
|
+
const managedDir = getManagedEngineDir(engineId, commandOptions);
|
|
373
|
+
const managedBinary = getManagedEngineBinary(engineId, commandOptions);
|
|
323
374
|
const tmp = temporaryBinaryPath(managedBinary);
|
|
324
375
|
await ensureDir(managedDir);
|
|
325
376
|
await fs.promises.copyFile(source, tmp);
|
|
@@ -332,7 +383,7 @@ export async function importManagedEngine(engineId = ENGINE.id, sourcePath, opti
|
|
|
332
383
|
}
|
|
333
384
|
|
|
334
385
|
await replaceFile(tmp, managedBinary);
|
|
335
|
-
const manifest = await writeManifest(engineId, managedBinary, { type: "local-file", path: source },
|
|
386
|
+
const manifest = await writeManifest(engineId, managedBinary, { type: "local-file", path: source }, commandOptions);
|
|
336
387
|
return { manifest, managedBinary };
|
|
337
388
|
}
|
|
338
389
|
|