@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 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
@@ -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: commandExists("ffmpeg"),
62
- arecord: commandExists("arecord"),
63
- sox: commandExists("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.ffmpeg ? "yes" : "no"}, arecord=${payload.recorders.arecord ? "yes" : "no"}, sox=${payload.recorders.sox ? "yes" : "no"}`,
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 result = spawnSync("opencode", ["plugin", packageName(), ...pluginArgs], { stdio: "inherit" });
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
- return typeof candidate === "string" ? path.resolve(candidate) : "";
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
- if (isExecutable(candidate)) return candidate;
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
- if (process.platform === "linux" && commandExists("arecord")) {
186
- const result = spawnSync("arecord", ["-L"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
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" && commandExists("ffmpeg")) {
195
- const result = spawnSync("ffmpeg", ["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""], {
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" && commandExists("ffmpeg")) {
207
- const result = spawnSync("ffmpeg", ["-hide_banner", "-f", "dshow", "-list_devices", "true", "-i", "dummy"], {
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" && commandExists("arecord")) {
263
+ if (process.platform === "linux" && arecordCommand) {
223
264
  recorders.push({
224
265
  label: mic ? `arecord (${mic})` : "arecord (default)",
225
- command: "arecord",
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" && commandExists("ffmpeg")) {
271
+ if (process.platform === "linux" && ffmpegCommand) {
231
272
  if (!mic) {
232
273
  recorders.push({
233
274
  label: "ffmpeg pulse (default)",
234
- command: "ffmpeg",
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: "ffmpeg",
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" && commandExists("ffmpeg")) {
287
+ if (process.platform === "darwin" && ffmpegCommand) {
247
288
  recorders.push({
248
289
  label: `ffmpeg avfoundation (${mic || ":0"})`,
249
- command: "ffmpeg",
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" && commandExists("ffmpeg")) {
255
- const inputs = [...new Set([normalizeWindowsAudioInput(mic), "audio=default"])];
256
- for (const input of inputs) {
257
- recorders.push({
258
- label: `ffmpeg dshow (${input.replace(/^audio=/, "")})`,
259
- command: "ffmpeg",
260
- args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "dshow", "-i", input, "-ac", "1", "-ar", "16000", file],
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 (commandExists("sox")) {
309
+ if (soxCommand) {
266
310
  recorders.push({
267
311
  label: "sox default",
268
- command: "sox",
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hxnnxs/opencode-voice",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Local voice input plugin for OpenCode",
5
5
  "type": "module",
6
6
  "license": "MIT",