@hxnnxs/opencode-voice 0.1.5 → 0.1.7
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 +13 -0
- package/bin/opencode-voice.js +49 -5
- package/lib/download.js +1 -1
- package/lib/engine.js +69 -25
- package/lib/engines.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.7 - 2026-06-17
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Hardened Windows ffmpeg resolution to handle executable-resolution edge cases (extensionless bundled binary paths and local module fallback), so recorder startup can use the actual resolved binary path.
|
|
10
|
+
- Improved diagnostics to print exact recorder command paths and quick per-command probe results in `opencode-voice doctor` output.
|
|
11
|
+
|
|
12
|
+
## 0.1.6 - 2026-06-17
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Fixed Windows recorder startup to use resolved recorder command paths (including bundled ffmpeg) directly when spawning, preventing startup failures when ffmpeg is present only by absolute path.
|
|
17
|
+
|
|
5
18
|
## 0.1.5 - 2026-06-17
|
|
6
19
|
|
|
7
20
|
### Fixed
|
package/bin/opencode-voice.js
CHANGED
|
@@ -32,6 +32,34 @@ function packageName() {
|
|
|
32
32
|
return process.env.OPENCODE_VOICE_PACKAGE || manifest.name || "opencode-voice";
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function probeCommand(command, args = ["-version"]) {
|
|
36
|
+
if (!command) return { ok: false, message: "missing" };
|
|
37
|
+
try {
|
|
38
|
+
const result = spawnSync(command, args, {
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
41
|
+
timeout: 2000,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (result.error) {
|
|
45
|
+
return { ok: false, message: result.error.message };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
message: `exit ${result.status}`,
|
|
52
|
+
stderr: (result.stderr || "").trim(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const output = `${result.stdout || ""}${result.stderr || ""}`;
|
|
57
|
+
return { ok: true, versionLine: output.split("\n").filter(Boolean)[0] || "" };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
35
63
|
async function runtime() {
|
|
36
64
|
const [engine, models, engines] = await Promise.all([import("../lib/engine.js"), import("../lib/models.js"), import("../lib/engines.js")]);
|
|
37
65
|
return { ...engine, ...models, ...engines };
|
|
@@ -40,6 +68,7 @@ async function runtime() {
|
|
|
40
68
|
async function doctor() {
|
|
41
69
|
const {
|
|
42
70
|
commandExists,
|
|
71
|
+
resolveCommand,
|
|
43
72
|
getAudioDir,
|
|
44
73
|
getCacheDir,
|
|
45
74
|
getEngineStatus,
|
|
@@ -50,6 +79,9 @@ async function doctor() {
|
|
|
50
79
|
|
|
51
80
|
const engine = getEngineStatus("whisper.cpp");
|
|
52
81
|
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");
|
|
53
85
|
const payload = {
|
|
54
86
|
platform: `${process.platform}-${process.arch}`,
|
|
55
87
|
cacheDir: getCacheDir(),
|
|
@@ -58,9 +90,15 @@ async function doctor() {
|
|
|
58
90
|
engine,
|
|
59
91
|
probe,
|
|
60
92
|
recorders: {
|
|
61
|
-
ffmpeg
|
|
62
|
-
arecord
|
|
63
|
-
sox
|
|
93
|
+
ffmpeg,
|
|
94
|
+
arecord,
|
|
95
|
+
sox,
|
|
96
|
+
ffmpegPresent: commandExists("ffmpeg"),
|
|
97
|
+
arecordPresent: commandExists("arecord"),
|
|
98
|
+
soxPresent: commandExists("sox"),
|
|
99
|
+
ffmpegProbe: probeCommand(ffmpeg),
|
|
100
|
+
arecordProbe: probeCommand(arecord, ["--help"]),
|
|
101
|
+
soxProbe: probeCommand(sox, ["--help"]),
|
|
64
102
|
},
|
|
65
103
|
microphones: listMicrophones(),
|
|
66
104
|
};
|
|
@@ -80,7 +118,7 @@ async function doctor() {
|
|
|
80
118
|
`Managed engine dir: ${engine.managedDir}`,
|
|
81
119
|
`whisper-cli: ${engine.resolvedBinary || "missing"}`,
|
|
82
120
|
`Probe: ${probe.ok ? "ok" : probe.message}`,
|
|
83
|
-
`Recorders: ffmpeg=${payload.recorders.
|
|
121
|
+
`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})` : ""}`,
|
|
84
122
|
`Microphones: ${payload.microphones.join(", ")}`,
|
|
85
123
|
].join("\n"),
|
|
86
124
|
);
|
|
@@ -162,7 +200,13 @@ function printEngineRetry({ error, nextAttempt, attempts }) {
|
|
|
162
200
|
|
|
163
201
|
async function installCommand() {
|
|
164
202
|
const pluginArgs = args.filter((arg) => arg !== "--no-engine");
|
|
165
|
-
const
|
|
203
|
+
const spawnOptions = { stdio: "inherit" };
|
|
204
|
+
if (process.platform === "win32") spawnOptions.shell = true;
|
|
205
|
+
const result = spawnSync("opencode", ["plugin", packageName(), ...pluginArgs], spawnOptions);
|
|
206
|
+
if (result.error) {
|
|
207
|
+
console.error(`Failed to run opencode: ${result.error.message}`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
166
210
|
if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
|
|
167
211
|
|
|
168
212
|
if (!hasFlag("--no-engine")) {
|
package/lib/download.js
CHANGED
|
@@ -70,7 +70,7 @@ function contentRangeStart(value) {
|
|
|
70
70
|
return match ? Number(match[1]) : null;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
async function replaceFile(source, destination) {
|
|
73
|
+
export async function replaceFile(source, destination) {
|
|
74
74
|
await fs.promises.unlink(destination).catch(() => {});
|
|
75
75
|
|
|
76
76
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
package/lib/engine.js
CHANGED
|
@@ -24,6 +24,31 @@ function isExecutable(file) {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function executableExtensions() {
|
|
28
|
+
if (process.platform !== "win32") return [""];
|
|
29
|
+
return (process.env.PATHEXT || ".EXE;.CMD;.BAT")
|
|
30
|
+
.split(";")
|
|
31
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.filter((entry) => entry.startsWith("."));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveExecutable(file) {
|
|
37
|
+
const normalized = path.resolve(file);
|
|
38
|
+
if (isExecutable(normalized)) return normalized;
|
|
39
|
+
|
|
40
|
+
if (path.extname(normalized) || process.platform !== "win32") {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const ext of executableExtensions()) {
|
|
45
|
+
const candidate = `${normalized}${ext}`;
|
|
46
|
+
if (isExecutable(candidate)) return candidate;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
|
|
27
52
|
function executableNames(command) {
|
|
28
53
|
const names = [command];
|
|
29
54
|
if (path.extname(command)) return names;
|
|
@@ -46,12 +71,18 @@ function bundledFfmpegPath() {
|
|
|
46
71
|
const resolveFromDependency = () => {
|
|
47
72
|
try {
|
|
48
73
|
const candidate = require("ffmpeg-static");
|
|
49
|
-
|
|
74
|
+
if (typeof candidate !== "string") return "";
|
|
75
|
+
return resolveExecutable(candidate);
|
|
50
76
|
} catch {
|
|
51
77
|
return "";
|
|
52
78
|
}
|
|
53
79
|
};
|
|
54
80
|
|
|
81
|
+
const resolveFromLocalModule = () => {
|
|
82
|
+
const fallback = path.join(PLUGIN_ROOT, "node_modules", "ffmpeg-static", "ffmpeg");
|
|
83
|
+
return resolveExecutable(fallback);
|
|
84
|
+
};
|
|
85
|
+
|
|
55
86
|
const installDependency = () => {
|
|
56
87
|
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
57
88
|
try {
|
|
@@ -66,6 +97,7 @@ function bundledFfmpegPath() {
|
|
|
66
97
|
};
|
|
67
98
|
|
|
68
99
|
let candidate = resolveFromDependency();
|
|
100
|
+
if (!candidate) candidate = resolveFromLocalModule();
|
|
69
101
|
if (!candidate && !ffmpegStaticInstallAttempted) {
|
|
70
102
|
ffmpegStaticInstallAttempted = true;
|
|
71
103
|
if (installDependency()) {
|
|
@@ -73,6 +105,8 @@ function bundledFfmpegPath() {
|
|
|
73
105
|
}
|
|
74
106
|
}
|
|
75
107
|
|
|
108
|
+
if (!candidate) candidate = resolveFromLocalModule();
|
|
109
|
+
|
|
76
110
|
ffmpegStaticPathCache = candidate || "";
|
|
77
111
|
return ffmpegStaticPathCache;
|
|
78
112
|
}
|
|
@@ -134,7 +168,8 @@ function candidateCommands(command, options = {}) {
|
|
|
134
168
|
export function resolveCommand(command, options = {}) {
|
|
135
169
|
for (const candidate of candidateCommands(command, options)) {
|
|
136
170
|
if (looksLikePath(candidate)) {
|
|
137
|
-
|
|
171
|
+
const resolved = resolveExecutable(candidate);
|
|
172
|
+
if (resolved) return resolved;
|
|
138
173
|
continue;
|
|
139
174
|
}
|
|
140
175
|
|
|
@@ -182,8 +217,11 @@ function parseWindowsMicrophones(stderr) {
|
|
|
182
217
|
}
|
|
183
218
|
|
|
184
219
|
export function listMicrophones() {
|
|
185
|
-
|
|
186
|
-
|
|
220
|
+
const arecordCommand = resolveCommand("arecord");
|
|
221
|
+
const ffmpegCommand = resolveCommand("ffmpeg");
|
|
222
|
+
|
|
223
|
+
if (process.platform === "linux" && arecordCommand) {
|
|
224
|
+
const result = spawnSync(arecordCommand, ["-L"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
187
225
|
const devices = result.stdout
|
|
188
226
|
.split(/\r?\n/)
|
|
189
227
|
.map((line) => line.trim())
|
|
@@ -191,8 +229,8 @@ export function listMicrophones() {
|
|
|
191
229
|
return ["default", ...devices.filter((item) => item !== "default")];
|
|
192
230
|
}
|
|
193
231
|
|
|
194
|
-
if (process.platform === "darwin" &&
|
|
195
|
-
const result = spawnSync(
|
|
232
|
+
if (process.platform === "darwin" && ffmpegCommand) {
|
|
233
|
+
const result = spawnSync(ffmpegCommand, ["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""], {
|
|
196
234
|
encoding: "utf8",
|
|
197
235
|
stdio: ["ignore", "ignore", "pipe"],
|
|
198
236
|
});
|
|
@@ -203,8 +241,8 @@ export function listMicrophones() {
|
|
|
203
241
|
.map((id) => `:${id}`);
|
|
204
242
|
}
|
|
205
243
|
|
|
206
|
-
if (process.platform === "win32" &&
|
|
207
|
-
const result = spawnSync(
|
|
244
|
+
if (process.platform === "win32" && ffmpegCommand) {
|
|
245
|
+
const result = spawnSync(ffmpegCommand, ["-hide_banner", "-f", "dshow", "-list_devices", "true", "-i", "dummy"], {
|
|
208
246
|
encoding: "utf8",
|
|
209
247
|
stdio: ["ignore", "ignore", "pipe"],
|
|
210
248
|
});
|
|
@@ -218,54 +256,60 @@ export function listMicrophones() {
|
|
|
218
256
|
function buildRecorders(file, settings = {}) {
|
|
219
257
|
const mic = settings.mic || "";
|
|
220
258
|
const recorders = [];
|
|
259
|
+
const arecordCommand = resolveCommand("arecord", settings);
|
|
260
|
+
const ffmpegCommand = resolveCommand("ffmpeg", settings);
|
|
261
|
+
const soxCommand = resolveCommand("sox", settings);
|
|
221
262
|
|
|
222
|
-
if (process.platform === "linux" &&
|
|
263
|
+
if (process.platform === "linux" && arecordCommand) {
|
|
223
264
|
recorders.push({
|
|
224
265
|
label: mic ? `arecord (${mic})` : "arecord (default)",
|
|
225
|
-
command:
|
|
266
|
+
command: arecordCommand,
|
|
226
267
|
args: ["-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "wav", ...(mic ? ["-D", mic] : []), file],
|
|
227
268
|
});
|
|
228
269
|
}
|
|
229
270
|
|
|
230
|
-
if (process.platform === "linux" &&
|
|
271
|
+
if (process.platform === "linux" && ffmpegCommand) {
|
|
231
272
|
if (!mic) {
|
|
232
273
|
recorders.push({
|
|
233
274
|
label: "ffmpeg pulse (default)",
|
|
234
|
-
command:
|
|
275
|
+
command: ffmpegCommand,
|
|
235
276
|
args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "pulse", "-i", "default", "-ac", "1", "-ar", "16000", file],
|
|
236
277
|
});
|
|
237
278
|
}
|
|
238
279
|
|
|
239
280
|
recorders.push({
|
|
240
281
|
label: `ffmpeg alsa (${mic || "default"})`,
|
|
241
|
-
command:
|
|
282
|
+
command: ffmpegCommand,
|
|
242
283
|
args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "alsa", "-i", mic || "default", "-ac", "1", "-ar", "16000", file],
|
|
243
284
|
});
|
|
244
285
|
}
|
|
245
286
|
|
|
246
|
-
if (process.platform === "darwin" &&
|
|
287
|
+
if (process.platform === "darwin" && ffmpegCommand) {
|
|
247
288
|
recorders.push({
|
|
248
289
|
label: `ffmpeg avfoundation (${mic || ":0"})`,
|
|
249
|
-
command:
|
|
290
|
+
command: ffmpegCommand,
|
|
250
291
|
args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "avfoundation", "-i", mic || ":0", "-ac", "1", "-ar", "16000", file],
|
|
251
292
|
});
|
|
252
293
|
}
|
|
253
294
|
|
|
254
|
-
if (process.platform === "win32" &&
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
295
|
+
if (process.platform === "win32" && ffmpegCommand) {
|
|
296
|
+
const ffmpegCmd = ffmpegCommand;
|
|
297
|
+
if (ffmpegCmd) {
|
|
298
|
+
const inputs = [...new Set([normalizeWindowsAudioInput(mic), "audio=default"])];
|
|
299
|
+
for (const input of inputs) {
|
|
300
|
+
recorders.push({
|
|
301
|
+
label: `ffmpeg dshow (${input.replace(/^audio=/, "")})`,
|
|
302
|
+
command: ffmpegCmd,
|
|
303
|
+
args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "dshow", "-i", input, "-ac", "1", "-ar", "16000", file],
|
|
304
|
+
});
|
|
305
|
+
}
|
|
262
306
|
}
|
|
263
307
|
}
|
|
264
308
|
|
|
265
|
-
if (
|
|
309
|
+
if (soxCommand) {
|
|
266
310
|
recorders.push({
|
|
267
311
|
label: "sox default",
|
|
268
|
-
command:
|
|
312
|
+
command: soxCommand,
|
|
269
313
|
args: ["-d", "-r", "16000", "-c", "1", "-b", "16", file],
|
|
270
314
|
});
|
|
271
315
|
}
|
package/lib/engines.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createGunzip } from "node:zlib";
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { Readable, Transform } from "node:stream";
|
|
7
7
|
import { pipeline } from "node:stream/promises";
|
|
8
|
-
import { sha256, ensureDir } from "./download.js";
|
|
8
|
+
import { sha256, ensureDir, replaceFile } from "./download.js";
|
|
9
9
|
import { getCacheDir } from "./models.js";
|
|
10
10
|
import { getBundledEngineDir, resolveCommand } from "./engine.js";
|
|
11
11
|
|