@hxnnxs/opencode-voice 0.1.4 → 0.1.6

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,18 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## 0.1.6 - 2026-06-17
6
+
7
+ ### Fixed
8
+
9
+ - 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.
10
+
11
+ ## 0.1.5 - 2026-06-17
12
+
13
+ ### Fixed
14
+
15
+ - Recovered missing `ffmpeg-static` at runtime on Windows by installing it locally when not present, preventing `No recorder found` failures after fresh plugin installs.
16
+
5
17
  ## 0.1.4 - 2026-06-16
6
18
 
7
19
  ### Added
@@ -162,7 +162,13 @@ function printEngineRetry({ error, nextAttempt, attempts }) {
162
162
 
163
163
  async function installCommand() {
164
164
  const pluginArgs = args.filter((arg) => arg !== "--no-engine");
165
- const result = spawnSync("opencode", ["plugin", packageName(), ...pluginArgs], { stdio: "inherit" });
165
+ const spawnOptions = { stdio: "inherit" };
166
+ if (process.platform === "win32") spawnOptions.shell = true;
167
+ const result = spawnSync("opencode", ["plugin", packageName(), ...pluginArgs], spawnOptions);
168
+ if (result.error) {
169
+ console.error(`Failed to run opencode: ${result.error.message}`);
170
+ process.exit(1);
171
+ }
166
172
  if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
167
173
 
168
174
  if (!hasFlag("--no-engine")) {
package/index.js CHANGED
@@ -362,10 +362,13 @@ function showDiagnostics(ctx) {
362
362
  const model = getModel(settings.model);
363
363
  const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir };
364
364
  const whisperCli = resolveCommand("whisper-cli", commandOptions);
365
+ const ffmpeg = resolveCommand("ffmpeg", commandOptions);
366
+ const arecord = resolveCommand("arecord", commandOptions);
367
+ const sox = resolveCommand("sox", commandOptions);
365
368
  const engine = getEngineStatus("whisper.cpp", ctx.options, settings);
366
369
  const lines = [
367
370
  `Platform: ${process.platform}-${process.arch}`,
368
- `Recorder: ffmpeg=${commandExists("ffmpeg") ? "yes" : "no"}, arecord=${commandExists("arecord") ? "yes" : "no"}, sox=${commandExists("sox") ? "yes" : "no"}`,
371
+ `Recorder: ffmpeg=${ffmpeg ? "yes" : "no"}${ffmpeg ? ` (${ffmpeg})` : ""}, arecord=${arecord ? "yes" : "no"}, sox=${sox ? "yes" : "no"}`,
369
372
  `Engine: ${engine.id}`,
370
373
  `Engine source: ${engine.source}`,
371
374
  `whisper-cli: ${whisperCli || "missing"}`,
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
@@ -3,10 +3,15 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createRequire } from "node:module";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
6
7
  import { ensureDir } from "./download.js";
7
8
  import { getAudioDir, getEnginesDir, getModelPath } from "./models.js";
8
9
 
9
10
  const require = createRequire(import.meta.url);
11
+ const FFMPEG_STATIC_PACKAGE = "ffmpeg-static@^5.2.0";
12
+ const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ let ffmpegStaticPathCache;
14
+ let ffmpegStaticInstallAttempted = false;
10
15
 
11
16
  const RECORDING_MIN_BYTES = 44;
12
17
 
@@ -36,12 +41,40 @@ function executableNames(command) {
36
41
 
37
42
  function bundledFfmpegPath() {
38
43
  if (process.platform !== "win32") return "";
39
- try {
40
- const candidate = require("ffmpeg-static");
41
- return typeof candidate === "string" ? candidate : "";
42
- } catch {
43
- return "";
44
+ if (ffmpegStaticPathCache !== undefined) return ffmpegStaticPathCache;
45
+
46
+ const resolveFromDependency = () => {
47
+ try {
48
+ const candidate = require("ffmpeg-static");
49
+ return typeof candidate === "string" ? path.resolve(candidate) : "";
50
+ } catch {
51
+ return "";
52
+ }
53
+ };
54
+
55
+ const installDependency = () => {
56
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
57
+ try {
58
+ const result = spawnSync(npm, ["install", "--no-save", "--no-audit", "--no-fund", FFMPEG_STATIC_PACKAGE], {
59
+ cwd: PLUGIN_ROOT,
60
+ stdio: "ignore",
61
+ });
62
+ return result.status === 0;
63
+ } catch {
64
+ return false;
65
+ }
66
+ };
67
+
68
+ let candidate = resolveFromDependency();
69
+ if (!candidate && !ffmpegStaticInstallAttempted) {
70
+ ffmpegStaticInstallAttempted = true;
71
+ if (installDependency()) {
72
+ candidate = resolveFromDependency();
73
+ }
44
74
  }
75
+
76
+ ffmpegStaticPathCache = candidate || "";
77
+ return ffmpegStaticPathCache;
45
78
  }
46
79
 
47
80
  function platformKey(options = {}) {
@@ -149,8 +182,11 @@ function parseWindowsMicrophones(stderr) {
149
182
  }
150
183
 
151
184
  export function listMicrophones() {
152
- if (process.platform === "linux" && commandExists("arecord")) {
153
- const result = spawnSync("arecord", ["-L"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
185
+ const arecordCommand = resolveCommand("arecord");
186
+ const ffmpegCommand = resolveCommand("ffmpeg");
187
+
188
+ if (process.platform === "linux" && arecordCommand) {
189
+ const result = spawnSync(arecordCommand, ["-L"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
154
190
  const devices = result.stdout
155
191
  .split(/\r?\n/)
156
192
  .map((line) => line.trim())
@@ -158,8 +194,8 @@ export function listMicrophones() {
158
194
  return ["default", ...devices.filter((item) => item !== "default")];
159
195
  }
160
196
 
161
- if (process.platform === "darwin" && commandExists("ffmpeg")) {
162
- const result = spawnSync("ffmpeg", ["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""], {
197
+ if (process.platform === "darwin" && ffmpegCommand) {
198
+ const result = spawnSync(ffmpegCommand, ["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""], {
163
199
  encoding: "utf8",
164
200
  stdio: ["ignore", "ignore", "pipe"],
165
201
  });
@@ -170,8 +206,8 @@ export function listMicrophones() {
170
206
  .map((id) => `:${id}`);
171
207
  }
172
208
 
173
- if (process.platform === "win32" && commandExists("ffmpeg")) {
174
- const result = spawnSync("ffmpeg", ["-hide_banner", "-f", "dshow", "-list_devices", "true", "-i", "dummy"], {
209
+ if (process.platform === "win32" && ffmpegCommand) {
210
+ const result = spawnSync(ffmpegCommand, ["-hide_banner", "-f", "dshow", "-list_devices", "true", "-i", "dummy"], {
175
211
  encoding: "utf8",
176
212
  stdio: ["ignore", "ignore", "pipe"],
177
213
  });
@@ -185,54 +221,60 @@ export function listMicrophones() {
185
221
  function buildRecorders(file, settings = {}) {
186
222
  const mic = settings.mic || "";
187
223
  const recorders = [];
224
+ const arecordCommand = resolveCommand("arecord", settings);
225
+ const ffmpegCommand = resolveCommand("ffmpeg", settings);
226
+ const soxCommand = resolveCommand("sox", settings);
188
227
 
189
- if (process.platform === "linux" && commandExists("arecord")) {
228
+ if (process.platform === "linux" && arecordCommand) {
190
229
  recorders.push({
191
230
  label: mic ? `arecord (${mic})` : "arecord (default)",
192
- command: "arecord",
231
+ command: arecordCommand,
193
232
  args: ["-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "wav", ...(mic ? ["-D", mic] : []), file],
194
233
  });
195
234
  }
196
235
 
197
- if (process.platform === "linux" && commandExists("ffmpeg")) {
236
+ if (process.platform === "linux" && ffmpegCommand) {
198
237
  if (!mic) {
199
238
  recorders.push({
200
239
  label: "ffmpeg pulse (default)",
201
- command: "ffmpeg",
240
+ command: ffmpegCommand,
202
241
  args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "pulse", "-i", "default", "-ac", "1", "-ar", "16000", file],
203
242
  });
204
243
  }
205
244
 
206
245
  recorders.push({
207
246
  label: `ffmpeg alsa (${mic || "default"})`,
208
- command: "ffmpeg",
247
+ command: ffmpegCommand,
209
248
  args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "alsa", "-i", mic || "default", "-ac", "1", "-ar", "16000", file],
210
249
  });
211
250
  }
212
251
 
213
- if (process.platform === "darwin" && commandExists("ffmpeg")) {
252
+ if (process.platform === "darwin" && ffmpegCommand) {
214
253
  recorders.push({
215
254
  label: `ffmpeg avfoundation (${mic || ":0"})`,
216
- command: "ffmpeg",
255
+ command: ffmpegCommand,
217
256
  args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "avfoundation", "-i", mic || ":0", "-ac", "1", "-ar", "16000", file],
218
257
  });
219
258
  }
220
259
 
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
- });
260
+ if (process.platform === "win32" && ffmpegCommand) {
261
+ const ffmpegCmd = ffmpegCommand;
262
+ if (ffmpegCmd) {
263
+ const inputs = [...new Set([normalizeWindowsAudioInput(mic), "audio=default"])];
264
+ for (const input of inputs) {
265
+ recorders.push({
266
+ label: `ffmpeg dshow (${input.replace(/^audio=/, "")})`,
267
+ command: ffmpegCmd,
268
+ args: ["-hide_banner", "-loglevel", "error", "-y", "-f", "dshow", "-i", input, "-ac", "1", "-ar", "16000", file],
269
+ });
270
+ }
229
271
  }
230
272
  }
231
273
 
232
- if (commandExists("sox")) {
274
+ if (soxCommand) {
233
275
  recorders.push({
234
276
  label: "sox default",
235
- command: "sox",
277
+ command: soxCommand,
236
278
  args: ["-d", "-r", "16000", "-c", "1", "-b", "16", file],
237
279
  });
238
280
  }
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.4",
3
+ "version": "0.1.6",
4
4
  "description": "Local voice input plugin for OpenCode",
5
5
  "type": "module",
6
6
  "license": "MIT",