@hxnnxs/opencode-voice 0.1.2 → 0.1.3
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 +7 -0
- package/index.js +4 -4
- package/lib/download.js +105 -39
- package/lib/engines.js +3 -1
- package/lib/models.js +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.3 - 2026-06-15
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Made Windows engine and model downloads more reliable with five install attempts, longer transfer stall timeouts, safer resume validation, and retrying final file replacement.
|
|
10
|
+
- Added HuggingFace fallback mirrors for Whisper models that have upstream mirror assets.
|
|
11
|
+
|
|
5
12
|
## 0.1.2 - 2026-06-15
|
|
6
13
|
|
|
7
14
|
### Fixed
|
package/index.js
CHANGED
|
@@ -164,11 +164,11 @@ async function ensureDownloaded(ctx, model, settings) {
|
|
|
164
164
|
const startedAt = Date.now();
|
|
165
165
|
let lastRender = 0;
|
|
166
166
|
let speedBps = 0;
|
|
167
|
-
renderDownloadStatus(ctx, model, { state: "starting", downloaded: 0, total: model.sizeMB ? model.sizeMB * 1024 * 1024 : 0, percent: 0, attempt: 1, attempts:
|
|
167
|
+
renderDownloadStatus(ctx, model, { state: "starting", downloaded: 0, total: model.sizeMB ? model.sizeMB * 1024 * 1024 : 0, percent: 0, attempt: 1, attempts: 5, speedBps });
|
|
168
168
|
|
|
169
169
|
toast(ctx.api, `Downloading ${model.name}...`);
|
|
170
170
|
await downloadModel(model, ctx.options, settings, {
|
|
171
|
-
retries:
|
|
171
|
+
retries: 5,
|
|
172
172
|
onProgress: (progress) => {
|
|
173
173
|
const now = Date.now();
|
|
174
174
|
const elapsed = Math.max(1, (now - startedAt) / 1000);
|
|
@@ -202,10 +202,10 @@ async function ensureEngineReady(ctx, settings) {
|
|
|
202
202
|
if (probe.ok) return true;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
renderEngineInstallStatus(ctx, { state: "registry", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts:
|
|
205
|
+
renderEngineInstallStatus(ctx, { state: "registry", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts: 5 });
|
|
206
206
|
toast(ctx.api, "Installing local voice engine...");
|
|
207
207
|
await installManagedEngine("whisper.cpp", commandOptions, settings, {
|
|
208
|
-
retries:
|
|
208
|
+
retries: 5,
|
|
209
209
|
onProgress: (progress) => renderEngineInstallStatus(ctx, progress),
|
|
210
210
|
onRetry: ({ error, nextAttempt, attempts }) => {
|
|
211
211
|
renderEngineInstallStatus(ctx, { state: "downloading", downloaded: 0, total: 0, percent: 0, attempt: nextAttempt, attempts });
|
package/lib/download.js
CHANGED
|
@@ -4,6 +4,9 @@ import { Readable, Transform } from "node:stream";
|
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
5
|
import { getModelPath, getModelsDir, getModelVerificationPath } from "./models.js";
|
|
6
6
|
|
|
7
|
+
const DEFAULT_DOWNLOAD_RETRIES = 5;
|
|
8
|
+
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120000;
|
|
9
|
+
|
|
7
10
|
function sleep(ms) {
|
|
8
11
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
12
|
}
|
|
@@ -33,18 +36,55 @@ export async function writeModelVerificationMarker(model, file, options = {}, se
|
|
|
33
36
|
|
|
34
37
|
async function fetchWithTimeout(url, init = {}, timeoutMs = 60000) {
|
|
35
38
|
const controller = new AbortController();
|
|
36
|
-
|
|
39
|
+
let timer;
|
|
40
|
+
const reset = () => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
43
|
+
};
|
|
44
|
+
const clear = () => clearTimeout(timer);
|
|
45
|
+
reset();
|
|
46
|
+
|
|
37
47
|
try {
|
|
38
|
-
|
|
48
|
+
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
49
|
+
return { response, signal: controller.signal, reset, clear };
|
|
39
50
|
} catch (error) {
|
|
51
|
+
clear();
|
|
40
52
|
if (error?.name === "AbortError") throw new Error(`Download timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
41
53
|
throw error;
|
|
42
|
-
} finally {
|
|
43
|
-
clearTimeout(timer);
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
|
|
57
|
+
function downloadError(error, url, timeoutMs) {
|
|
58
|
+
if (error?.name === "AbortError" || error?.code === "ABORT_ERR") {
|
|
59
|
+
return new Error(`Download timed out or stalled from ${url} after ${Math.round(timeoutMs / 1000)}s`);
|
|
60
|
+
}
|
|
61
|
+
return error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function modelUrls(model) {
|
|
65
|
+
return [...new Set([...(Array.isArray(model.urls) ? model.urls : []), model.url].filter(Boolean))];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function contentRangeStart(value) {
|
|
69
|
+
const match = /^bytes\s+(\d+)-/i.exec(value || "");
|
|
70
|
+
return match ? Number(match[1]) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function replaceFile(source, destination) {
|
|
74
|
+
await fs.promises.unlink(destination).catch(() => {});
|
|
75
|
+
|
|
76
|
+
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
77
|
+
try {
|
|
78
|
+
await fs.promises.rename(source, destination);
|
|
79
|
+
return;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (attempt >= 5 || !["EBUSY", "EPERM", "EACCES"].includes(error?.code)) throw error;
|
|
82
|
+
await sleep(100 * attempt);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function downloadModelOnce(model, sourceUrl, options = {}, settings = {}, hooks = {}, attempt = 1, attempts = 1) {
|
|
48
88
|
if (!model?.implemented || !model.url || !model.filename) {
|
|
49
89
|
throw new Error(`${model?.name || "Model"} is not supported by the current local engine yet`);
|
|
50
90
|
}
|
|
@@ -66,57 +106,83 @@ async function downloadModelOnce(model, options = {}, settings = {}, hooks = {},
|
|
|
66
106
|
|
|
67
107
|
const partialSize = fs.existsSync(partial) ? fs.statSync(partial).size : 0;
|
|
68
108
|
const headers = partialSize > 0 ? { Range: `bytes=${partialSize}-` } : undefined;
|
|
69
|
-
const
|
|
109
|
+
const timeoutMs = Number(options.downloadTimeoutMs || hooks.timeoutMs || DEFAULT_DOWNLOAD_TIMEOUT_MS);
|
|
110
|
+
const request = await fetchWithTimeout(sourceUrl, { headers }, timeoutMs);
|
|
111
|
+
const response = request.response;
|
|
70
112
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
113
|
+
try {
|
|
114
|
+
if (response.status === 416 && partialSize > 0) {
|
|
115
|
+
await fs.promises.unlink(partial).catch(() => {});
|
|
116
|
+
return downloadModelOnce(model, sourceUrl, options, settings, hooks, attempt, attempts);
|
|
117
|
+
}
|
|
75
118
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
119
|
+
if (!response.ok && response.status !== 206) {
|
|
120
|
+
throw new Error(`Download failed from ${sourceUrl}: HTTP ${response.status}`);
|
|
121
|
+
}
|
|
79
122
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
123
|
+
const append = response.status === 206 && partialSize > 0;
|
|
124
|
+
const rangeStart = append ? contentRangeStart(response.headers.get("content-range")) : null;
|
|
125
|
+
if (append && rangeStart !== null && rangeStart !== partialSize) {
|
|
126
|
+
await fs.promises.unlink(partial).catch(() => {});
|
|
127
|
+
throw new Error(`Download resume mismatch from ${sourceUrl}: expected byte ${partialSize}, got ${rangeStart}`);
|
|
128
|
+
}
|
|
84
129
|
|
|
85
|
-
|
|
130
|
+
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
131
|
+
const total = contentLength > 0 ? contentLength + (append ? partialSize : 0) : model.sizeMB ? model.sizeMB * 1024 * 1024 : 0;
|
|
132
|
+
let downloaded = append ? partialSize : 0;
|
|
86
133
|
|
|
87
|
-
|
|
88
|
-
if (!body) throw new Error("Download failed: empty response body");
|
|
134
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total, percent: total ? (downloaded / total) * 100 : 0, attempt, attempts });
|
|
89
135
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
downloaded += chunk.length ?? chunk.byteLength ?? 0;
|
|
93
|
-
hooks.onProgress?.({ state: "downloading", downloaded, total, percent: total ? (downloaded / total) * 100 : 0, attempt, attempts });
|
|
94
|
-
callback(null, chunk);
|
|
95
|
-
},
|
|
96
|
-
});
|
|
136
|
+
const body = response.body?.getReader ? Readable.fromWeb(response.body) : response.body;
|
|
137
|
+
if (!body) throw new Error("Download failed: empty response body");
|
|
97
138
|
|
|
98
|
-
|
|
139
|
+
const progress = new Transform({
|
|
140
|
+
transform(chunk, _encoding, callback) {
|
|
141
|
+
request.reset();
|
|
142
|
+
downloaded += chunk.length ?? chunk.byteLength ?? 0;
|
|
143
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total, percent: total ? (downloaded / total) * 100 : 0, attempt, attempts });
|
|
144
|
+
callback(null, chunk);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
99
147
|
|
|
100
|
-
|
|
148
|
+
try {
|
|
149
|
+
await pipeline(body, progress, fs.createWriteStream(partial, { flags: append ? "a" : "w" }), { signal: request.signal });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw downloadError(error, sourceUrl, timeoutMs);
|
|
152
|
+
}
|
|
153
|
+
request.clear();
|
|
101
154
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
155
|
+
const actualSize = fs.statSync(partial).size;
|
|
156
|
+
downloaded = actualSize;
|
|
157
|
+
if (contentLength > 0 && actualSize < total) {
|
|
158
|
+
throw new Error(`Download incomplete from ${sourceUrl}: got ${actualSize} of ${total} bytes`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
hooks.onProgress?.({ state: "verifying", downloaded, total: total || downloaded, percent: 100, attempt, attempts });
|
|
106
162
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
163
|
+
if (!(await verifyModel(model, partial))) {
|
|
164
|
+
await fs.promises.unlink(partial).catch(() => {});
|
|
165
|
+
throw new Error(`SHA256 mismatch for ${model.name}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await replaceFile(partial, file);
|
|
169
|
+
await writeModelVerificationMarker(model, file, options, settings);
|
|
170
|
+
hooks.onProgress?.({ state: "done", downloaded, total: total || downloaded, percent: 100, attempt, attempts });
|
|
171
|
+
return file;
|
|
172
|
+
} finally {
|
|
173
|
+
request.clear();
|
|
174
|
+
}
|
|
111
175
|
}
|
|
112
176
|
|
|
113
177
|
export async function downloadModel(model, options = {}, settings = {}, hooks = {}) {
|
|
114
|
-
const
|
|
178
|
+
const urls = modelUrls(model);
|
|
179
|
+
const attempts = Math.max(Number(options.downloadRetries || hooks.retries || DEFAULT_DOWNLOAD_RETRIES), urls.length || 1);
|
|
115
180
|
let lastError;
|
|
116
181
|
|
|
117
182
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
183
|
+
const sourceUrl = urls[(attempt - 1) % urls.length];
|
|
118
184
|
try {
|
|
119
|
-
return await downloadModelOnce(model, options, settings, hooks, attempt, attempts);
|
|
185
|
+
return await downloadModelOnce(model, sourceUrl, options, settings, hooks, attempt, attempts);
|
|
120
186
|
} catch (error) {
|
|
121
187
|
lastError = error;
|
|
122
188
|
if (attempt >= attempts) break;
|
package/lib/engines.js
CHANGED
|
@@ -18,6 +18,8 @@ const ENGINE = {
|
|
|
18
18
|
probeContains: ["--model", "--file"],
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
const DEFAULT_ENGINE_DOWNLOAD_RETRIES = 5;
|
|
22
|
+
|
|
21
23
|
function sleep(ms) {
|
|
22
24
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
25
|
}
|
|
@@ -257,7 +259,7 @@ export async function installManagedEngine(engineId = ENGINE.id, options = {}, s
|
|
|
257
259
|
await ensureDir(managedDir);
|
|
258
260
|
const compressedFile = path.join(managedDir, `${path.basename(asset.url || `${platform}.gz`)}.download`);
|
|
259
261
|
const tmpBinary = temporaryBinaryPath(managedBinary);
|
|
260
|
-
const attempts = Number(options.engineDownloadRetries || hooks.retries ||
|
|
262
|
+
const attempts = Number(options.engineDownloadRetries || hooks.retries || DEFAULT_ENGINE_DOWNLOAD_RETRIES);
|
|
261
263
|
let lastError;
|
|
262
264
|
|
|
263
265
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
package/lib/models.js
CHANGED
|
@@ -14,6 +14,7 @@ export const MODELS = [
|
|
|
14
14
|
recommended: true,
|
|
15
15
|
filename: "ggml-small.bin",
|
|
16
16
|
url: "https://blob.handy.computer/ggml-small.bin",
|
|
17
|
+
urls: ["https://blob.handy.computer/ggml-small.bin", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin"],
|
|
17
18
|
sha256: "1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b",
|
|
18
19
|
sizeMB: 465,
|
|
19
20
|
languages: "multilingual",
|
|
@@ -26,6 +27,7 @@ export const MODELS = [
|
|
|
26
27
|
implemented: true,
|
|
27
28
|
filename: "whisper-medium-q4_1.bin",
|
|
28
29
|
url: "https://blob.handy.computer/whisper-medium-q4_1.bin",
|
|
30
|
+
urls: ["https://blob.handy.computer/whisper-medium-q4_1.bin"],
|
|
29
31
|
sha256: "79283fc1f9fe12ca3248543fbd54b73292164d8df5a16e095e2bceeaaabddf57",
|
|
30
32
|
sizeMB: 469,
|
|
31
33
|
languages: "multilingual",
|
|
@@ -38,6 +40,7 @@ export const MODELS = [
|
|
|
38
40
|
implemented: true,
|
|
39
41
|
filename: "ggml-large-v3-turbo.bin",
|
|
40
42
|
url: "https://blob.handy.computer/ggml-large-v3-turbo.bin",
|
|
43
|
+
urls: ["https://blob.handy.computer/ggml-large-v3-turbo.bin", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin"],
|
|
41
44
|
sha256: "1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69",
|
|
42
45
|
sizeMB: 1549,
|
|
43
46
|
languages: "multilingual",
|
|
@@ -50,6 +53,7 @@ export const MODELS = [
|
|
|
50
53
|
implemented: true,
|
|
51
54
|
filename: "ggml-large-v3-q5_0.bin",
|
|
52
55
|
url: "https://blob.handy.computer/ggml-large-v3-q5_0.bin",
|
|
56
|
+
urls: ["https://blob.handy.computer/ggml-large-v3-q5_0.bin", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-q5_0.bin"],
|
|
53
57
|
sha256: "d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1",
|
|
54
58
|
sizeMB: 1031,
|
|
55
59
|
languages: "multilingual",
|