@hxnnxs/opencode-voice 0.1.0
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 +17 -0
- package/CONTRIBUTING.md +22 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +36 -0
- package/README.es.md +174 -0
- package/README.md +174 -0
- package/README.ru.md +174 -0
- package/README.zh.md +174 -0
- package/SECURITY.md +19 -0
- package/assets/opencode-voice-dark.svg +27 -0
- package/assets/opencode-voice-light.svg +27 -0
- package/bin/opencode-voice.js +179 -0
- package/index.js +819 -0
- package/lib/download.js +129 -0
- package/lib/engine.js +406 -0
- package/lib/engines.js +337 -0
- package/lib/models.js +161 -0
- package/package.json +70 -0
package/lib/engines.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createGunzip } from "node:zlib";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { Readable, Transform } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { sha256, ensureDir } from "./download.js";
|
|
9
|
+
import { getCacheDir } from "./models.js";
|
|
10
|
+
import { getBundledEngineDir, resolveCommand } from "./engine.js";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_ENGINE_REGISTRY_URL = "https://github.com/ihxnnxs/opencode-voice/releases/download/v0.1.0/registry.json";
|
|
13
|
+
|
|
14
|
+
const ENGINE = {
|
|
15
|
+
id: "whisper.cpp",
|
|
16
|
+
command: "whisper-cli",
|
|
17
|
+
probeArgs: ["--help"],
|
|
18
|
+
probeContains: ["--model", "--file"],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function executableName() {
|
|
26
|
+
return process.platform === "win32" ? "whisper-cli.exe" : "whisper-cli";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizePlatform(value) {
|
|
30
|
+
if (value === "x64" || value === "arm64") return value;
|
|
31
|
+
if (value === "amd64") return "x64";
|
|
32
|
+
if (value === "aarch64") return "arm64";
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getEnginePlatformKey(options = {}) {
|
|
37
|
+
return `${options.platform || process.platform}-${normalizePlatform(options.arch || process.arch)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getManagedEngineDir(engineId = ENGINE.id, options = {}, settings = {}) {
|
|
41
|
+
if (engineId !== ENGINE.id) throw new Error(`Unsupported engine: ${engineId}`);
|
|
42
|
+
return getBundledEngineDir(ENGINE.command, { ...options, downloadDir: settings.downloadDir });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getManagedEngineBinary(engineId = ENGINE.id, options = {}, settings = {}) {
|
|
46
|
+
return path.join(getManagedEngineDir(engineId, options, settings), executableName());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getEngineManifestPath(engineId = ENGINE.id, options = {}, settings = {}) {
|
|
50
|
+
return path.join(getManagedEngineDir(engineId, options, settings), "manifest.json");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readEngineManifest(engineId = ENGINE.id, options = {}, settings = {}) {
|
|
54
|
+
const file = getEngineManifestPath(engineId, options, settings);
|
|
55
|
+
if (!fs.existsSync(file)) return null;
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function engineSource(resolved, managedBinary, options = {}) {
|
|
64
|
+
if (!resolved) return "missing";
|
|
65
|
+
if (path.resolve(resolved) === path.resolve(managedBinary)) return "managed";
|
|
66
|
+
if (options.whisperCli && path.resolve(resolved) === path.resolve(options.whisperCli)) return "option";
|
|
67
|
+
if (process.env.OPENCODE_VOICE_WHISPER_CLI && path.resolve(resolved) === path.resolve(process.env.OPENCODE_VOICE_WHISPER_CLI)) return "env";
|
|
68
|
+
return "system";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getEngineStatus(engineId = ENGINE.id, options = {}, settings = {}) {
|
|
72
|
+
const commandOptions = { ...options, downloadDir: settings.downloadDir };
|
|
73
|
+
const managedDir = getManagedEngineDir(engineId, options, settings);
|
|
74
|
+
const managedBinary = getManagedEngineBinary(engineId, options, settings);
|
|
75
|
+
const manifest = readEngineManifest(engineId, options, settings);
|
|
76
|
+
const resolvedBinary = resolveCommand(ENGINE.command, commandOptions);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
id: engineId,
|
|
80
|
+
command: ENGINE.command,
|
|
81
|
+
platform: getEnginePlatformKey(options),
|
|
82
|
+
cacheDir: getCacheDir(options, settings),
|
|
83
|
+
managedDir,
|
|
84
|
+
managedBinary,
|
|
85
|
+
managedInstalled: fs.existsSync(managedBinary),
|
|
86
|
+
manifest,
|
|
87
|
+
resolvedBinary,
|
|
88
|
+
source: engineSource(resolvedBinary, managedBinary, options),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function probeEngine(engineId = ENGINE.id, binaryPath, options = {}) {
|
|
93
|
+
if (engineId !== ENGINE.id) throw new Error(`Unsupported engine: ${engineId}`);
|
|
94
|
+
if (!binaryPath) return { ok: false, message: "missing binary" };
|
|
95
|
+
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
let output = "";
|
|
98
|
+
const localLib = path.join(os.homedir(), ".local", "lib");
|
|
99
|
+
const env = {
|
|
100
|
+
...process.env,
|
|
101
|
+
PATH: [path.dirname(binaryPath), path.join(os.homedir(), ".local", "bin"), process.env.PATH].filter(Boolean).join(path.delimiter),
|
|
102
|
+
LD_LIBRARY_PATH: [localLib, process.env.LD_LIBRARY_PATH].filter(Boolean).join(path.delimiter),
|
|
103
|
+
DYLD_LIBRARY_PATH: [localLib, process.env.DYLD_LIBRARY_PATH].filter(Boolean).join(path.delimiter),
|
|
104
|
+
...options.env,
|
|
105
|
+
};
|
|
106
|
+
const proc = spawn(binaryPath, ENGINE.probeArgs, { stdio: ["ignore", "pipe", "pipe"], env });
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
try {
|
|
109
|
+
proc.kill("SIGKILL");
|
|
110
|
+
} catch {}
|
|
111
|
+
resolve({ ok: false, message: "probe timed out" });
|
|
112
|
+
}, options.timeoutMs || 10000);
|
|
113
|
+
|
|
114
|
+
proc.stdout.on("data", (chunk) => {
|
|
115
|
+
output += chunk.toString();
|
|
116
|
+
});
|
|
117
|
+
proc.stderr.on("data", (chunk) => {
|
|
118
|
+
output += chunk.toString();
|
|
119
|
+
});
|
|
120
|
+
proc.on("error", (error) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
resolve({ ok: false, message: error.message });
|
|
123
|
+
});
|
|
124
|
+
proc.on("exit", () => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
const ok = ENGINE.probeContains.every((item) => output.includes(item));
|
|
127
|
+
resolve({ ok, message: ok ? "ok" : "unexpected whisper-cli help output" });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function fetchWithTimeout(url, init = {}, timeoutMs = 60000) {
|
|
133
|
+
const controller = new AbortController();
|
|
134
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
135
|
+
try {
|
|
136
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error?.name === "AbortError") throw new Error(`Engine download timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
139
|
+
throw error;
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readJsonUrl(url, options = {}) {
|
|
146
|
+
if (url.startsWith("file://")) return JSON.parse(await fs.promises.readFile(new URL(url), "utf8"));
|
|
147
|
+
if (!/^https?:\/\//.test(url)) return JSON.parse(await fs.promises.readFile(path.resolve(url), "utf8"));
|
|
148
|
+
|
|
149
|
+
const response = await fetchWithTimeout(url, {}, Number(options.timeoutMs || 60000));
|
|
150
|
+
if (!response.ok) throw new Error(`Engine registry failed: HTTP ${response.status}`);
|
|
151
|
+
return response.json();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getEngineRegistryUrl(options = {}) {
|
|
155
|
+
return options.engineRegistry || process.env.OPENCODE_VOICE_ENGINE_REGISTRY || DEFAULT_ENGINE_REGISTRY_URL;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function loadEngineRegistry(options = {}) {
|
|
159
|
+
const url = getEngineRegistryUrl(options);
|
|
160
|
+
const registry = await readJsonUrl(url, options);
|
|
161
|
+
if (registry?.schema !== "opencode-voice.engines.v1") throw new Error("Unsupported engine registry schema");
|
|
162
|
+
return { url, registry };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function engineDefinition(registry, engineId) {
|
|
166
|
+
if (Array.isArray(registry.engines)) return registry.engines.find((engine) => engine.id === engineId);
|
|
167
|
+
return registry.engines?.[engineId];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function selectEngineAsset(registry, engineId, platform) {
|
|
171
|
+
const engine = engineDefinition(registry, engineId);
|
|
172
|
+
if (!engine) throw new Error(`Engine not found in registry: ${engineId}`);
|
|
173
|
+
const asset = engine.assets?.[platform];
|
|
174
|
+
if (!asset) throw new Error(`No managed ${engineId} engine asset for ${platform}`);
|
|
175
|
+
if (asset.kind !== "single-binary-gzip") throw new Error(`Unsupported engine asset kind: ${asset.kind}`);
|
|
176
|
+
return { engine, asset };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function downloadAsset(asset, compressedFile, hooks = {}, attempt = 1, attempts = 1, options = {}) {
|
|
180
|
+
if (asset.url.startsWith("file://") || !/^https?:\/\//.test(asset.url)) {
|
|
181
|
+
const source = asset.url.startsWith("file://") ? new URL(asset.url) : path.resolve(asset.url);
|
|
182
|
+
const stat = await fs.promises.stat(source);
|
|
183
|
+
let downloaded = 0;
|
|
184
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: stat.size, percent: 0, attempt, attempts });
|
|
185
|
+
const progress = new Transform({
|
|
186
|
+
transform(chunk, _encoding, callback) {
|
|
187
|
+
downloaded += chunk.length ?? chunk.byteLength ?? 0;
|
|
188
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: stat.size, percent: stat.size ? (downloaded / stat.size) * 100 : 0, attempt, attempts });
|
|
189
|
+
callback(null, chunk);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
await pipeline(fs.createReadStream(source), progress, fs.createWriteStream(compressedFile));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const response = await fetchWithTimeout(asset.url, {}, Number(options.downloadTimeoutMs || hooks.timeoutMs || 120000));
|
|
197
|
+
if (!response.ok) throw new Error(`Engine download failed: HTTP ${response.status}`);
|
|
198
|
+
|
|
199
|
+
const contentLength = Number(response.headers.get("content-length") || asset.size || 0);
|
|
200
|
+
let downloaded = 0;
|
|
201
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: contentLength, percent: 0, attempt, attempts });
|
|
202
|
+
|
|
203
|
+
const body = response.body?.getReader ? Readable.fromWeb(response.body) : response.body;
|
|
204
|
+
if (!body) throw new Error("Engine download failed: empty response body");
|
|
205
|
+
|
|
206
|
+
const progress = new Transform({
|
|
207
|
+
transform(chunk, _encoding, callback) {
|
|
208
|
+
downloaded += chunk.length ?? chunk.byteLength ?? 0;
|
|
209
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: contentLength, percent: contentLength ? (downloaded / contentLength) * 100 : 0, attempt, attempts });
|
|
210
|
+
callback(null, chunk);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await pipeline(body, progress, fs.createWriteStream(compressedFile));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function writeManifest(engineId, managedBinary, source, options = {}) {
|
|
218
|
+
const hash = await sha256(managedBinary);
|
|
219
|
+
const stat = await fs.promises.stat(managedBinary);
|
|
220
|
+
const manifest = {
|
|
221
|
+
schema: "opencode-voice.engine-install.v1",
|
|
222
|
+
id: engineId,
|
|
223
|
+
kind: "cli",
|
|
224
|
+
command: ENGINE.command,
|
|
225
|
+
platform: getEnginePlatformKey(options),
|
|
226
|
+
version: source.version || "local-import",
|
|
227
|
+
source,
|
|
228
|
+
files: [{ path: path.basename(managedBinary), sha256: hash, size: stat.size }],
|
|
229
|
+
installedAt: new Date().toISOString(),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
await fs.promises.writeFile(path.join(path.dirname(managedBinary), "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
233
|
+
return manifest;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function installManagedEngine(engineId = ENGINE.id, options = {}, settings = {}, hooks = {}) {
|
|
237
|
+
if (engineId !== ENGINE.id) throw new Error(`Unsupported engine: ${engineId}`);
|
|
238
|
+
|
|
239
|
+
const managedDir = getManagedEngineDir(engineId, options, settings);
|
|
240
|
+
const managedBinary = getManagedEngineBinary(engineId, options, settings);
|
|
241
|
+
if (!hooks.force && fs.existsSync(managedBinary)) {
|
|
242
|
+
const probe = await probeEngine(engineId, managedBinary);
|
|
243
|
+
if (probe.ok) return { manifest: readEngineManifest(engineId, options, settings), managedBinary, skipped: true };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
hooks.onProgress?.({ state: "registry", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts: 1 });
|
|
247
|
+
const { url: registryUrl, registry } = await loadEngineRegistry(options);
|
|
248
|
+
const platform = getEnginePlatformKey(options);
|
|
249
|
+
const { engine, asset } = selectEngineAsset(registry, engineId, platform);
|
|
250
|
+
|
|
251
|
+
await ensureDir(managedDir);
|
|
252
|
+
const compressedFile = path.join(managedDir, `${path.basename(asset.url || `${platform}.gz`)}.download`);
|
|
253
|
+
const tmpBinary = `${managedBinary}.tmp-${process.pid}`;
|
|
254
|
+
const attempts = Number(options.engineDownloadRetries || hooks.retries || 3);
|
|
255
|
+
let lastError;
|
|
256
|
+
|
|
257
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
258
|
+
try {
|
|
259
|
+
await fs.promises.unlink(compressedFile).catch(() => {});
|
|
260
|
+
await fs.promises.unlink(tmpBinary).catch(() => {});
|
|
261
|
+
await downloadAsset(asset, compressedFile, hooks, attempt, attempts, options);
|
|
262
|
+
|
|
263
|
+
hooks.onProgress?.({ state: "verifying", downloaded: asset.size || 0, total: asset.size || 0, percent: 100, attempt, attempts });
|
|
264
|
+
if (asset.sha256 && (await sha256(compressedFile)) !== asset.sha256) throw new Error("Engine archive SHA256 mismatch");
|
|
265
|
+
|
|
266
|
+
hooks.onProgress?.({ state: "decompressing", downloaded: asset.size || 0, total: asset.size || 0, percent: 100, attempt, attempts });
|
|
267
|
+
await pipeline(fs.createReadStream(compressedFile), createGunzip(), fs.createWriteStream(tmpBinary));
|
|
268
|
+
await fs.promises.chmod(tmpBinary, Number.parseInt(asset.binary?.mode || "755", 8));
|
|
269
|
+
|
|
270
|
+
hooks.onProgress?.({ state: "verifying-binary", downloaded: asset.binary?.size || 0, total: asset.binary?.size || 0, percent: 100, attempt, attempts });
|
|
271
|
+
if (asset.binary?.sha256 && (await sha256(tmpBinary)) !== asset.binary.sha256) throw new Error("Engine binary SHA256 mismatch");
|
|
272
|
+
|
|
273
|
+
hooks.onProgress?.({ state: "probing", downloaded: asset.binary?.size || 0, total: asset.binary?.size || 0, percent: 100, attempt, attempts });
|
|
274
|
+
const probe = await probeEngine(engineId, tmpBinary);
|
|
275
|
+
if (!probe.ok) throw new Error(`Engine probe failed: ${probe.message}`);
|
|
276
|
+
|
|
277
|
+
await fs.promises.unlink(managedBinary).catch(() => {});
|
|
278
|
+
await fs.promises.rename(tmpBinary, managedBinary);
|
|
279
|
+
await fs.promises.unlink(compressedFile).catch(() => {});
|
|
280
|
+
const manifest = await writeManifest(
|
|
281
|
+
engineId,
|
|
282
|
+
managedBinary,
|
|
283
|
+
{
|
|
284
|
+
type: "registry",
|
|
285
|
+
registry: registryUrl,
|
|
286
|
+
url: asset.url,
|
|
287
|
+
version: engine.version || registry.version || "registry",
|
|
288
|
+
upstream: engine.upstream,
|
|
289
|
+
},
|
|
290
|
+
options,
|
|
291
|
+
);
|
|
292
|
+
hooks.onProgress?.({ state: "done", downloaded: asset.binary?.size || asset.size || 0, total: asset.binary?.size || asset.size || 0, percent: 100, attempt, attempts });
|
|
293
|
+
return { manifest, managedBinary, skipped: false };
|
|
294
|
+
} catch (error) {
|
|
295
|
+
lastError = error;
|
|
296
|
+
await fs.promises.unlink(tmpBinary).catch(() => {});
|
|
297
|
+
if (attempt >= attempts) break;
|
|
298
|
+
hooks.onRetry?.({ error, attempt, attempts, nextAttempt: attempt + 1 });
|
|
299
|
+
await sleep(Math.min(1000 * attempt, 3000));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
throw lastError;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function importManagedEngine(engineId = ENGINE.id, sourcePath, options = {}, settings = {}) {
|
|
307
|
+
if (!sourcePath) throw new Error("Pass a whisper-cli path to import");
|
|
308
|
+
if (engineId !== ENGINE.id) throw new Error(`Unsupported engine: ${engineId}`);
|
|
309
|
+
|
|
310
|
+
const source = path.resolve(sourcePath.replace(/^~(?=$|\/)/, os.homedir()));
|
|
311
|
+
const stat = await fs.promises.stat(source).catch(() => null);
|
|
312
|
+
if (!stat?.isFile()) throw new Error(`Engine binary not found: ${source}`);
|
|
313
|
+
|
|
314
|
+
const managedDir = getManagedEngineDir(engineId, options, settings);
|
|
315
|
+
const managedBinary = getManagedEngineBinary(engineId, options, settings);
|
|
316
|
+
const tmp = `${managedBinary}.tmp-${process.pid}`;
|
|
317
|
+
await ensureDir(managedDir);
|
|
318
|
+
await fs.promises.copyFile(source, tmp);
|
|
319
|
+
await fs.promises.chmod(tmp, 0o755);
|
|
320
|
+
|
|
321
|
+
const probe = await probeEngine(engineId, tmp);
|
|
322
|
+
if (!probe.ok) {
|
|
323
|
+
await fs.promises.unlink(tmp).catch(() => {});
|
|
324
|
+
throw new Error(`Imported binary failed probe: ${probe.message}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await fs.promises.unlink(managedBinary).catch(() => {});
|
|
328
|
+
await fs.promises.rename(tmp, managedBinary);
|
|
329
|
+
const manifest = await writeManifest(engineId, managedBinary, { type: "local-file", path: source }, options);
|
|
330
|
+
return { manifest, managedBinary };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function removeManagedEngine(engineId = ENGINE.id, options = {}, settings = {}) {
|
|
334
|
+
const dir = getManagedEngineDir(engineId, options, settings);
|
|
335
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
336
|
+
return dir;
|
|
337
|
+
}
|
package/lib/models.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const PLUGIN_ID = "opencode-voice";
|
|
6
|
+
export const DEFAULT_MODEL_ID = "whisper-small";
|
|
7
|
+
|
|
8
|
+
export const MODELS = [
|
|
9
|
+
{
|
|
10
|
+
id: "whisper-small",
|
|
11
|
+
name: "Whisper Small",
|
|
12
|
+
engine: "whisper.cpp",
|
|
13
|
+
implemented: true,
|
|
14
|
+
recommended: true,
|
|
15
|
+
filename: "ggml-small.bin",
|
|
16
|
+
url: "https://blob.handy.computer/ggml-small.bin",
|
|
17
|
+
sha256: "1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b",
|
|
18
|
+
sizeMB: 465,
|
|
19
|
+
languages: "multilingual",
|
|
20
|
+
description: "Good first local model. Multilingual, including Russian, but not tiny.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "whisper-medium-q4_1",
|
|
24
|
+
name: "Whisper Medium Q4_1",
|
|
25
|
+
engine: "whisper.cpp",
|
|
26
|
+
implemented: true,
|
|
27
|
+
filename: "whisper-medium-q4_1.bin",
|
|
28
|
+
url: "https://blob.handy.computer/whisper-medium-q4_1.bin",
|
|
29
|
+
sha256: "79283fc1f9fe12ca3248543fbd54b73292164d8df5a16e095e2bceeaaabddf57",
|
|
30
|
+
sizeMB: 469,
|
|
31
|
+
languages: "multilingual",
|
|
32
|
+
description: "Better accuracy than Small with a quantized model size.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "whisper-turbo",
|
|
36
|
+
name: "Whisper Turbo",
|
|
37
|
+
engine: "whisper.cpp",
|
|
38
|
+
implemented: true,
|
|
39
|
+
filename: "ggml-large-v3-turbo.bin",
|
|
40
|
+
url: "https://blob.handy.computer/ggml-large-v3-turbo.bin",
|
|
41
|
+
sha256: "1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69",
|
|
42
|
+
sizeMB: 1549,
|
|
43
|
+
languages: "multilingual",
|
|
44
|
+
description: "Large and accurate. Download only if you want the bigger model.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "whisper-large-q5_0",
|
|
48
|
+
name: "Whisper Large Q5_0",
|
|
49
|
+
engine: "whisper.cpp",
|
|
50
|
+
implemented: true,
|
|
51
|
+
filename: "ggml-large-v3-q5_0.bin",
|
|
52
|
+
url: "https://blob.handy.computer/ggml-large-v3-q5_0.bin",
|
|
53
|
+
sha256: "d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1",
|
|
54
|
+
sizeMB: 1031,
|
|
55
|
+
languages: "multilingual",
|
|
56
|
+
description: "Accurate but slower. Good machines only.",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "parakeet-v3",
|
|
60
|
+
name: "Parakeet V3",
|
|
61
|
+
engine: "sidecar",
|
|
62
|
+
implemented: false,
|
|
63
|
+
sizeMB: 456,
|
|
64
|
+
languages: "25 European languages plus Russian/Ukrainian",
|
|
65
|
+
description: "Planned Handy-style sidecar model. Not enabled in this JS MVP yet.",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "gigaam-v3",
|
|
69
|
+
name: "GigaAM v3",
|
|
70
|
+
engine: "sidecar",
|
|
71
|
+
implemented: false,
|
|
72
|
+
sizeMB: 151,
|
|
73
|
+
languages: "Russian",
|
|
74
|
+
description: "Planned Russian-focused sidecar model. Not enabled in this JS MVP yet.",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "moonshine-small-streaming-en",
|
|
78
|
+
name: "Moonshine V2 Small",
|
|
79
|
+
engine: "sidecar",
|
|
80
|
+
implemented: false,
|
|
81
|
+
sizeMB: 99,
|
|
82
|
+
languages: "English",
|
|
83
|
+
description: "Planned fast English sidecar model. Not enabled in this JS MVP yet.",
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_SETTINGS = {
|
|
88
|
+
hotkey: "",
|
|
89
|
+
toggleHotkey: "ctrl+r",
|
|
90
|
+
submitHotkey: "",
|
|
91
|
+
model: DEFAULT_MODEL_ID,
|
|
92
|
+
language: "auto",
|
|
93
|
+
mic: "",
|
|
94
|
+
autoSubmit: false,
|
|
95
|
+
downloadDir: "",
|
|
96
|
+
onboardingDone: false,
|
|
97
|
+
setupSkipped: false,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function expandHome(value) {
|
|
101
|
+
if (!value) return value;
|
|
102
|
+
if (value === "~") return os.homedir();
|
|
103
|
+
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getCacheDir(options = {}, settings = {}) {
|
|
108
|
+
const configured = settings.downloadDir || options.downloadDir || process.env.OPENCODE_VOICE_DIR;
|
|
109
|
+
if (configured) return path.resolve(expandHome(configured));
|
|
110
|
+
|
|
111
|
+
const xdg = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
112
|
+
return path.join(xdg, PLUGIN_ID);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getModelsDir(options = {}, settings = {}) {
|
|
116
|
+
return path.join(getCacheDir(options, settings), "models");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getAudioDir(options = {}, settings = {}) {
|
|
120
|
+
return path.join(getCacheDir(options, settings), "recordings");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getEnginesDir(options = {}, settings = {}) {
|
|
124
|
+
return path.join(getCacheDir(options, settings), "engines");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getModel(id) {
|
|
128
|
+
return MODELS.find((model) => model.id === id) || MODELS.find((model) => model.id === DEFAULT_MODEL_ID);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function getModelPath(model, options = {}, settings = {}) {
|
|
132
|
+
if (!model?.filename) return "";
|
|
133
|
+
return path.join(getModelsDir(options, settings), model.filename);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getModelVerificationPath(model, options = {}, settings = {}) {
|
|
137
|
+
const file = getModelPath(model, options, settings);
|
|
138
|
+
return file ? `${file}.sha256` : "";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function isModelFilePresent(model, options = {}, settings = {}) {
|
|
142
|
+
const file = getModelPath(model, options, settings);
|
|
143
|
+
return Boolean(file && fs.existsSync(file) && fs.statSync(file).size > 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function isModelDownloaded(model, options = {}, settings = {}) {
|
|
147
|
+
if (!isModelFilePresent(model, options, settings)) return false;
|
|
148
|
+
if (!model?.sha256) return true;
|
|
149
|
+
|
|
150
|
+
const marker = getModelVerificationPath(model, options, settings);
|
|
151
|
+
if (!marker || !fs.existsSync(marker)) return false;
|
|
152
|
+
|
|
153
|
+
const value = fs.readFileSync(marker, "utf8").trim().toLowerCase();
|
|
154
|
+
return value === model.sha256.toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function formatSize(model) {
|
|
158
|
+
if (!model?.sizeMB) return "unknown size";
|
|
159
|
+
if (model.sizeMB >= 1000) return `${(model.sizeMB / 1000).toFixed(1)} GB`;
|
|
160
|
+
return `${model.sizeMB} MB`;
|
|
161
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hxnnxs/opencode-voice",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local voice input plugin for OpenCode",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ihxnnxs/opencode-voice.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/ihxnnxs/opencode-voice/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/ihxnnxs/opencode-voice#readme",
|
|
15
|
+
"main": "./index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./index.js"
|
|
19
|
+
},
|
|
20
|
+
"./tui": {
|
|
21
|
+
"import": "./index.js"
|
|
22
|
+
},
|
|
23
|
+
"./runtime": {
|
|
24
|
+
"import": "./lib/engine.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"opencode-voice": "bin/opencode-voice.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"assets/",
|
|
32
|
+
"bin/",
|
|
33
|
+
"lib/",
|
|
34
|
+
"index.js",
|
|
35
|
+
"CHANGELOG.md",
|
|
36
|
+
"CONTRIBUTING.md",
|
|
37
|
+
"PUBLISHING.md",
|
|
38
|
+
"README*.md",
|
|
39
|
+
"SECURITY.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"check": "node --check index.js && node --check lib/models.js && node --check lib/download.js && node --check lib/engine.js && node --check lib/engines.js && node --check bin/opencode-voice.js && node --check scripts/package-engine-asset.mjs && node --check scripts/build-engine-registry.mjs",
|
|
44
|
+
"prepack": "npm run check"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"provenance": true
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@opencode-ai/plugin": ">=1.17.4 <2"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"@opencode-ai/plugin": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"keywords": [
|
|
59
|
+
"opencode",
|
|
60
|
+
"opencode-plugin",
|
|
61
|
+
"voice",
|
|
62
|
+
"speech-to-text",
|
|
63
|
+
"whisper",
|
|
64
|
+
"whisper.cpp"
|
|
65
|
+
],
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=20",
|
|
68
|
+
"opencode": ">=1.17.4"
|
|
69
|
+
}
|
|
70
|
+
}
|