@hxnnxs/opencode-voice 0.1.2 → 0.1.4
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 +14 -0
- package/README.md +2 -2
- package/docs/README.es.md +2 -2
- package/docs/README.ru.md +2 -2
- package/docs/README.zh.md +2 -2
- package/index.js +6 -5
- package/lib/download.js +105 -39
- package/lib/engine.js +55 -0
- package/lib/engines.js +3 -1
- package/lib/models.js +4 -0
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.4 - 2026-06-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added bundled `ffmpeg` fallback for Windows recorder flow via `ffmpeg-static` so voice input no longer depends on user-installed `ffmpeg`.
|
|
10
|
+
- Added Windows DirectShow microphone discovery and input handling in the recorder layer.
|
|
11
|
+
|
|
12
|
+
## 0.1.3 - 2026-06-15
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Made Windows engine and model downloads more reliable with five install attempts, longer transfer stall timeouts, safer resume validation, and retrying final file replacement.
|
|
17
|
+
- Added HuggingFace fallback mirrors for Whisper models that have upstream mirror assets.
|
|
18
|
+
|
|
5
19
|
## 0.1.2 - 2026-06-15
|
|
6
20
|
|
|
7
21
|
### 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 | engine
|
|
115
|
+
| Windows | one-command engine/model install; recording uses `ffmpeg` with DirectShow (system or bundled `ffmpeg` fallback) |
|
|
116
116
|
|
|
117
117
|
### Architecture
|
|
118
118
|
|
|
@@ -139,7 +139,7 @@ Voice input needs native audio and STT binaries. The JS plugin manages OpenCode
|
|
|
139
139
|
- publish managed `whisper-cli` release assets before npm release
|
|
140
140
|
- Rust recorder sidecar with `cpal` and VAD
|
|
141
141
|
- Parakeet, GigaAM, SenseVoice, Canary, and Moonshine model support
|
|
142
|
-
- Windows recorder
|
|
142
|
+
- Windows recorder stability and UX polish
|
|
143
143
|
- faster streaming-style transcription
|
|
144
144
|
|
|
145
145
|
### Development
|
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 |
|
|
113
|
+
| Windows | instalación one-command de engine/model; grabación con `ffmpeg` + DirectShow (desde ffmpeg del sistema o fallback incluido) |
|
|
114
114
|
|
|
115
115
|
### Arquitectura
|
|
116
116
|
|
|
@@ -137,7 +137,7 @@ La entrada por voz necesita native audio y STT binaries. El plugin JS gestiona O
|
|
|
137
137
|
- publicar managed `whisper-cli` release assets antes del npm release
|
|
138
138
|
- Rust recorder sidecar con `cpal` y VAD
|
|
139
139
|
- soporte para Parakeet, GigaAM, SenseVoice, Canary y Moonshine
|
|
140
|
-
-
|
|
140
|
+
- Mejorar estabilidad y UX del recorder en Windows
|
|
141
141
|
- streaming-style transcription más rápida
|
|
142
142
|
|
|
143
143
|
### Desarrollo
|
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 |
|
|
113
|
+
| Windows | one-command engine/model install; запись через `ffmpeg` + DirectShow (через системный ffmpeg или встроенный fallback) |
|
|
114
114
|
|
|
115
115
|
### Архитектура
|
|
116
116
|
|
|
@@ -137,7 +137,7 @@ Voice input требует native audio и STT binaries. JS-плагин упр
|
|
|
137
137
|
- опубликовать managed `whisper-cli` release assets перед npm release
|
|
138
138
|
- Rust recorder sidecar с `cpal` и VAD
|
|
139
139
|
- поддержка Parakeet, GigaAM, SenseVoice, Canary и Moonshine
|
|
140
|
-
- Windows recorder
|
|
140
|
+
- Улучшение устойчивости и UX Windows recorder
|
|
141
141
|
- более быстрая streaming-style transcription
|
|
142
142
|
|
|
143
143
|
### Разработка
|
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
|
|
113
|
+
| Windows | 下载 engine/model 一条命令完成;通过 `ffmpeg` + DirectShow 录音(使用系统 ffmpeg 或内置备用) |
|
|
114
114
|
|
|
115
115
|
### 架构
|
|
116
116
|
|
|
@@ -137,7 +137,7 @@ ctrl+r -> 停止、转写并插入文本
|
|
|
137
137
|
- npm release 前发布 managed `whisper-cli` release assets
|
|
138
138
|
- 使用 `cpal` 和 VAD 的 Rust recorder sidecar
|
|
139
139
|
- 支持 Parakeet、GigaAM、SenseVoice、Canary 和 Moonshine
|
|
140
|
-
- Windows
|
|
140
|
+
- 更完善的 Windows 录音体验和稳定性
|
|
141
141
|
- 更快的 streaming-style transcription
|
|
142
142
|
|
|
143
143
|
### 开发
|
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 });
|
|
@@ -325,6 +325,7 @@ function showLanguagePicker(ctx) {
|
|
|
325
325
|
|
|
326
326
|
function showMicrophonePicker(ctx) {
|
|
327
327
|
const settings = readSettings(ctx.api.kv);
|
|
328
|
+
const placeholder = process.platform === "win32" ? "default, audio=default, \"Microphone (Name)\"" : "default, hw:0,0, pulse, :0, ...";
|
|
328
329
|
const devices = listMicrophones();
|
|
329
330
|
setDialog(ctx, "large", () =>
|
|
330
331
|
ctx.api.ui.DialogSelect({
|
|
@@ -339,7 +340,7 @@ function showMicrophonePicker(ctx) {
|
|
|
339
340
|
if (option.value === "__custom") {
|
|
340
341
|
showPrompt(ctx, {
|
|
341
342
|
title: "Custom microphone device",
|
|
342
|
-
placeholder
|
|
343
|
+
placeholder,
|
|
343
344
|
value: settings.mic,
|
|
344
345
|
onConfirm: (value) => {
|
|
345
346
|
writeSetting(ctx.api.kv, "mic", value.trim());
|
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/engine.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
6
|
import { ensureDir } from "./download.js";
|
|
6
7
|
import { getAudioDir, getEnginesDir, getModelPath } from "./models.js";
|
|
7
8
|
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
|
|
8
11
|
const RECORDING_MIN_BYTES = 44;
|
|
9
12
|
|
|
10
13
|
function isExecutable(file) {
|
|
@@ -31,6 +34,16 @@ function executableNames(command) {
|
|
|
31
34
|
return [...new Set(names)];
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
function bundledFfmpegPath() {
|
|
38
|
+
if (process.platform !== "win32") return "";
|
|
39
|
+
try {
|
|
40
|
+
const candidate = require("ffmpeg-static");
|
|
41
|
+
return typeof candidate === "string" ? candidate : "";
|
|
42
|
+
} catch {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
function platformKey(options = {}) {
|
|
35
48
|
return `${options.platform || process.platform}-${options.arch || process.arch}`;
|
|
36
49
|
}
|
|
@@ -52,6 +65,11 @@ function candidateCommands(command, options = {}) {
|
|
|
52
65
|
if (process.env.OPENCODE_VOICE_WHISPER_CLI) candidates.push(process.env.OPENCODE_VOICE_WHISPER_CLI);
|
|
53
66
|
}
|
|
54
67
|
|
|
68
|
+
if (command === "ffmpeg") {
|
|
69
|
+
const bundled = bundledFfmpegPath();
|
|
70
|
+
if (bundled) candidates.push(bundled);
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
const bundledDir = getBundledEngineDir(command, options);
|
|
56
74
|
if (bundledDir) {
|
|
57
75
|
for (const name of executableNames(command)) candidates.push(path.join(bundledDir, name));
|
|
@@ -113,6 +131,23 @@ function childEnv(options = {}) {
|
|
|
113
131
|
};
|
|
114
132
|
}
|
|
115
133
|
|
|
134
|
+
function normalizeWindowsAudioInput(device = "") {
|
|
135
|
+
const value = String(device).trim();
|
|
136
|
+
if (!value || value === "default") return "audio=default";
|
|
137
|
+
if (value.startsWith("audio=") || value.startsWith("video=")) return value;
|
|
138
|
+
return `audio=${value}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseWindowsMicrophones(stderr) {
|
|
142
|
+
const devices = new Set();
|
|
143
|
+
for (const line of stderr.split(/\r?\n/)) {
|
|
144
|
+
const match = line.match(/"([^"]+)"\s+\(audio\)/);
|
|
145
|
+
if (match?.[1]) devices.add(match[1]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return [...devices].filter(Boolean);
|
|
149
|
+
}
|
|
150
|
+
|
|
116
151
|
export function listMicrophones() {
|
|
117
152
|
if (process.platform === "linux" && commandExists("arecord")) {
|
|
118
153
|
const result = spawnSync("arecord", ["-L"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -135,6 +170,15 @@ export function listMicrophones() {
|
|
|
135
170
|
.map((id) => `:${id}`);
|
|
136
171
|
}
|
|
137
172
|
|
|
173
|
+
if (process.platform === "win32" && commandExists("ffmpeg")) {
|
|
174
|
+
const result = spawnSync("ffmpeg", ["-hide_banner", "-f", "dshow", "-list_devices", "true", "-i", "dummy"], {
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
177
|
+
});
|
|
178
|
+
const devices = parseWindowsMicrophones(result.stderr || "");
|
|
179
|
+
return ["default", ...devices.filter((device) => device !== "default")];
|
|
180
|
+
}
|
|
181
|
+
|
|
138
182
|
return ["default"];
|
|
139
183
|
}
|
|
140
184
|
|
|
@@ -174,6 +218,17 @@ function buildRecorders(file, settings = {}) {
|
|
|
174
218
|
});
|
|
175
219
|
}
|
|
176
220
|
|
|
221
|
+
if (process.platform === "win32" && commandExists("ffmpeg")) {
|
|
222
|
+
const inputs = [...new Set([normalizeWindowsAudioInput(mic), "audio=default"])];
|
|
223
|
+
for (const input of inputs) {
|
|
224
|
+
recorders.push({
|
|
225
|
+
label: `ffmpeg dshow (${input.replace(/^audio=/, "")})`,
|
|
226
|
+
command: "ffmpeg",
|
|
227
|
+
args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "dshow", "-i", input, "-ac", "1", "-ar", "16000", file],
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
177
232
|
if (commandExists("sox")) {
|
|
178
233
|
recorders.push({
|
|
179
234
|
label: "sox default",
|
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",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hxnnxs/opencode-voice",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Local voice input plugin for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,9 @@
|
|
|
45
45
|
"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",
|
|
46
46
|
"prepack": "npm run check"
|
|
47
47
|
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"ffmpeg-static": "^5.2.0"
|
|
50
|
+
},
|
|
48
51
|
"publishConfig": {
|
|
49
52
|
"access": "public",
|
|
50
53
|
"provenance": true
|