@hxnnxs/opencode-voice 0.1.6 → 0.1.8
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 +15 -0
- package/README.md +2 -2
- package/bin/opencode-voice.js +97 -8
- package/docs/README.es.md +2 -2
- package/docs/README.ru.md +2 -2
- package/docs/README.zh.md +2 -2
- package/index.js +62 -4
- package/lib/engine.js +432 -18
- package/lib/engines.js +2 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.8 - 2026-06-21
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Added a managed Windows recorder install: the plugin can now download, unpack, cache, and probe `ffmpeg.exe` itself before recording, so Windows voice input no longer depends on `ffmpeg-static` lifecycle scripts, npm recovery installs, or a user-installed recorder on `PATH`.
|
|
10
|
+
- Extended `opencode-voice doctor` diagnostics to prepare and report the managed Windows recorder path, manifest, install error, and probe result.
|
|
11
|
+
- Made managed native binary replacement retry-safe on Windows for both recorder and `whisper-cli` engine installs.
|
|
12
|
+
|
|
13
|
+
## 0.1.7 - 2026-06-17
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
- Improved diagnostics to print exact recorder command paths and quick per-command probe results in `opencode-voice doctor` output.
|
|
19
|
+
|
|
5
20
|
## 0.1.6 - 2026-06-17
|
|
6
21
|
|
|
7
22
|
### 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 | one-command engine/model install; recording uses `ffmpeg
|
|
115
|
+
| Windows | one-command engine/model/recorder install; recording uses DirectShow through a managed cached `ffmpeg.exe`, with system/bundled ffmpeg fallback |
|
|
116
116
|
|
|
117
117
|
### Architecture
|
|
118
118
|
|
|
@@ -128,7 +128,7 @@ Files:
|
|
|
128
128
|
- `index.js` - TUI plugin entrypoint, commands, dialogs, keymap layer
|
|
129
129
|
- `lib/models.js` - model registry, cache paths, default settings
|
|
130
130
|
- `lib/download.js` - resumable model download and SHA256 verification
|
|
131
|
-
- `lib/engine.js` - recorder selection and `whisper-cli` transcription
|
|
131
|
+
- `lib/engine.js` - recorder selection, managed Windows recorder install, and `whisper-cli` transcription
|
|
132
132
|
- `lib/engines.js` - managed native engine download, status, import, and removal
|
|
133
133
|
- `bin/opencode-voice.js` - install wrapper and diagnostics CLI
|
|
134
134
|
|
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 };
|
|
@@ -39,17 +67,41 @@ async function runtime() {
|
|
|
39
67
|
|
|
40
68
|
async function doctor() {
|
|
41
69
|
const {
|
|
42
|
-
|
|
70
|
+
ensureManagedRecorder,
|
|
71
|
+
resolveCommand,
|
|
43
72
|
getAudioDir,
|
|
44
73
|
getCacheDir,
|
|
45
74
|
getEngineStatus,
|
|
46
75
|
getModelsDir,
|
|
76
|
+
getRecorderStatus,
|
|
47
77
|
listMicrophones,
|
|
48
78
|
probeEngine,
|
|
49
79
|
} = await runtime();
|
|
50
80
|
|
|
81
|
+
const json = hasFlag("--json");
|
|
82
|
+
let recorderInstall = null;
|
|
83
|
+
let recorderInstallError = "";
|
|
84
|
+
if (process.platform === "win32") {
|
|
85
|
+
try {
|
|
86
|
+
recorderInstall = await ensureManagedRecorder(
|
|
87
|
+
{},
|
|
88
|
+
{},
|
|
89
|
+
json ? {} : { onProgress: printRecorderProgress, onRetry: printRecorderRetry },
|
|
90
|
+
);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
recorderInstallError = error instanceof Error ? error.message : String(error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const recorderOptions = process.platform === "win32" && recorderInstall?.resolvedBinary
|
|
97
|
+
? { ffmpeg: recorderInstall.resolvedBinary, skipFfmpegStaticInstall: true }
|
|
98
|
+
: {};
|
|
51
99
|
const engine = getEngineStatus("whisper.cpp");
|
|
52
100
|
const probe = engine.resolvedBinary ? await probeEngine("whisper.cpp", engine.resolvedBinary) : { ok: false, message: "missing binary" };
|
|
101
|
+
const recorder = getRecorderStatus(recorderOptions);
|
|
102
|
+
const ffmpeg = recorderInstall?.resolvedBinary || resolveCommand("ffmpeg", recorderOptions);
|
|
103
|
+
const arecord = resolveCommand("arecord", recorderOptions);
|
|
104
|
+
const sox = resolveCommand("sox", recorderOptions);
|
|
53
105
|
const payload = {
|
|
54
106
|
platform: `${process.platform}-${process.arch}`,
|
|
55
107
|
cacheDir: getCacheDir(),
|
|
@@ -57,15 +109,24 @@ async function doctor() {
|
|
|
57
109
|
recordingsDir: getAudioDir(),
|
|
58
110
|
engine,
|
|
59
111
|
probe,
|
|
112
|
+
recorder,
|
|
60
113
|
recorders: {
|
|
61
|
-
ffmpeg
|
|
62
|
-
arecord
|
|
63
|
-
sox
|
|
114
|
+
ffmpeg,
|
|
115
|
+
arecord,
|
|
116
|
+
sox,
|
|
117
|
+
ffmpegPresent: Boolean(ffmpeg),
|
|
118
|
+
arecordPresent: Boolean(arecord),
|
|
119
|
+
soxPresent: Boolean(sox),
|
|
120
|
+
ffmpegProbe: probeCommand(ffmpeg),
|
|
121
|
+
arecordProbe: probeCommand(arecord, ["--help"]),
|
|
122
|
+
soxProbe: probeCommand(sox, ["--help"]),
|
|
123
|
+
managedInstall: recorderInstall,
|
|
124
|
+
managedInstallError: recorderInstallError,
|
|
64
125
|
},
|
|
65
|
-
microphones: listMicrophones(),
|
|
126
|
+
microphones: listMicrophones(recorderOptions),
|
|
66
127
|
};
|
|
67
128
|
|
|
68
|
-
if (
|
|
129
|
+
if (json) {
|
|
69
130
|
console.log(JSON.stringify(payload, null, 2));
|
|
70
131
|
} else {
|
|
71
132
|
console.log(
|
|
@@ -80,13 +141,19 @@ async function doctor() {
|
|
|
80
141
|
`Managed engine dir: ${engine.managedDir}`,
|
|
81
142
|
`whisper-cli: ${engine.resolvedBinary || "missing"}`,
|
|
82
143
|
`Probe: ${probe.ok ? "ok" : probe.message}`,
|
|
83
|
-
`
|
|
144
|
+
`Recorder source: ${recorder.source}`,
|
|
145
|
+
`Managed recorder dir: ${recorder.managedDir}`,
|
|
146
|
+
`Managed recorder installed: ${recorder.managedInstalled ? "yes" : "no"}`,
|
|
147
|
+
`Managed recorder version: ${recorder.manifest?.version || "missing"}`,
|
|
148
|
+
`Managed recorder probe: ${payload.recorders.ffmpegProbe.ok ? "ok" : payload.recorders.ffmpegProbe.message}`,
|
|
149
|
+
...(recorderInstallError ? [`Managed recorder error: ${recorderInstallError}`] : []),
|
|
150
|
+
`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
151
|
`Microphones: ${payload.microphones.join(", ")}`,
|
|
85
152
|
].join("\n"),
|
|
86
153
|
);
|
|
87
154
|
}
|
|
88
155
|
|
|
89
|
-
if (!engine.resolvedBinary || !probe.ok) process.exitCode = 1;
|
|
156
|
+
if (!engine.resolvedBinary || !probe.ok || (process.platform === "win32" && !payload.recorders.ffmpegProbe.ok)) process.exitCode = 1;
|
|
90
157
|
}
|
|
91
158
|
|
|
92
159
|
async function engineCommand() {
|
|
@@ -160,6 +227,21 @@ function printEngineRetry({ error, nextAttempt, attempts }) {
|
|
|
160
227
|
console.warn(`engine retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`);
|
|
161
228
|
}
|
|
162
229
|
|
|
230
|
+
function printRecorderProgress(progress) {
|
|
231
|
+
const label = {
|
|
232
|
+
downloading: "download",
|
|
233
|
+
decompressing: "unpack",
|
|
234
|
+
probing: "probe",
|
|
235
|
+
done: "done",
|
|
236
|
+
}[progress.state] || progress.state || "recorder";
|
|
237
|
+
const percent = Number.isFinite(progress.percent) ? `${Math.round(progress.percent)}%` : "";
|
|
238
|
+
if (progress.state === "downloading" || progress.state === "done") console.log(`recorder ${label} ${percent}`.trim());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function printRecorderRetry({ error, nextAttempt, attempts }) {
|
|
242
|
+
console.warn(`recorder retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
163
245
|
async function installCommand() {
|
|
164
246
|
const pluginArgs = args.filter((arg) => arg !== "--no-engine");
|
|
165
247
|
const spawnOptions = { stdio: "inherit" };
|
|
@@ -177,6 +259,13 @@ async function installCommand() {
|
|
|
177
259
|
const engine = await installManagedEngine("whisper.cpp", {}, {}, { onProgress: printEngineProgress, onRetry: printEngineRetry });
|
|
178
260
|
console.log(`Managed voice engine ready: ${engine.managedBinary}`);
|
|
179
261
|
}
|
|
262
|
+
|
|
263
|
+
if (process.platform === "win32") {
|
|
264
|
+
const { ensureManagedRecorder } = await runtime();
|
|
265
|
+
console.log("Installing managed Windows recorder...");
|
|
266
|
+
const recorder = await ensureManagedRecorder({}, {}, { onProgress: printRecorderProgress, onRetry: printRecorderRetry });
|
|
267
|
+
console.log(`Managed Windows recorder ready: ${recorder.managedBinary || recorder.resolvedBinary}`);
|
|
268
|
+
}
|
|
180
269
|
}
|
|
181
270
|
|
|
182
271
|
if (command === "install") await installCommand();
|
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 | instalación one-command de engine/model; grabación con `ffmpeg`
|
|
113
|
+
| Windows | instalación one-command de engine/model/recorder; grabación con DirectShow mediante `ffmpeg.exe` managed en caché, con fallback a ffmpeg del sistema/incluido |
|
|
114
114
|
|
|
115
115
|
### Arquitectura
|
|
116
116
|
|
|
@@ -126,7 +126,7 @@ Archivos:
|
|
|
126
126
|
- `index.js` - entrada TUI, comandos, dialogs, keymap layer
|
|
127
127
|
- `lib/models.js` - registry de modelos, cache paths, default settings
|
|
128
128
|
- `lib/download.js` - descarga resumible y verificación SHA256
|
|
129
|
-
- `lib/engine.js` - selección de recorder y transcripción con `whisper-cli`
|
|
129
|
+
- `lib/engine.js` - selección de recorder, instalación managed del recorder Windows y transcripción con `whisper-cli`
|
|
130
130
|
- `lib/engines.js` - descarga, estado, importación y eliminación de managed native engine
|
|
131
131
|
- `bin/opencode-voice.js` - install wrapper y diagnostics CLI
|
|
132
132
|
|
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 | one-command engine/model install; запись через `ffmpeg
|
|
113
|
+
| Windows | one-command engine/model/recorder install; запись через DirectShow и managed cached `ffmpeg.exe`, с fallback на системный/встроенный ffmpeg |
|
|
114
114
|
|
|
115
115
|
### Архитектура
|
|
116
116
|
|
|
@@ -126,7 +126,7 @@ Hold-to-talk отключен по умолчанию, потому что termi
|
|
|
126
126
|
- `index.js` - TUI entrypoint, команды, dialogs, keymap layer
|
|
127
127
|
- `lib/models.js` - registry моделей, cache paths, default settings
|
|
128
128
|
- `lib/download.js` - resumable download и SHA256 verification
|
|
129
|
-
- `lib/engine.js` - выбор recorder и transcription через `whisper-cli`
|
|
129
|
+
- `lib/engine.js` - выбор recorder, managed Windows recorder install и transcription через `whisper-cli`
|
|
130
130
|
- `lib/engines.js` - managed native engine download, status, import и removal
|
|
131
131
|
- `bin/opencode-voice.js` - install wrapper и diagnostics CLI
|
|
132
132
|
|
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 |
|
|
113
|
+
| Windows | 一条命令安装 engine/model/recorder;通过 managed cached `ffmpeg.exe` + DirectShow 录音,并保留系统/内置 ffmpeg 备用 |
|
|
114
114
|
|
|
115
115
|
### 架构
|
|
116
116
|
|
|
@@ -126,7 +126,7 @@ ctrl+r -> 停止、转写并插入文本
|
|
|
126
126
|
- `index.js` - TUI 插件入口、命令、dialogs、keymap layer
|
|
127
127
|
- `lib/models.js` - 模型 registry、cache paths、default settings
|
|
128
128
|
- `lib/download.js` - 可续传下载和 SHA256 校验
|
|
129
|
-
- `lib/engine.js` - recorder
|
|
129
|
+
- `lib/engine.js` - recorder 选择、managed Windows recorder 安装和 `whisper-cli` 转写
|
|
130
130
|
- `lib/engines.js` - managed native engine 下载、状态、导入和移除
|
|
131
131
|
- `bin/opencode-voice.js` - install wrapper 和 diagnostics CLI
|
|
132
132
|
|
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MODELS, DEFAULT_SETTINGS, PLUGIN_ID, formatSize, getCacheDir, getModel, getModelPath, isModelDownloaded, isModelFilePresent } from "./lib/models.js";
|
|
2
2
|
import { downloadModel } from "./lib/download.js";
|
|
3
|
-
import { VoiceRuntime,
|
|
3
|
+
import { VoiceRuntime, ensureManagedRecorder, getRecorderStatus, listMicrophones, probeRecorder, resolveCommand } from "./lib/engine.js";
|
|
4
4
|
import { getEngineStatus, importManagedEngine, installManagedEngine, probeEngine, removeManagedEngine } from "./lib/engines.js";
|
|
5
5
|
|
|
6
6
|
const KV = {
|
|
@@ -141,6 +141,33 @@ function renderEngineInstallStatus(ctx, progress = {}) {
|
|
|
141
141
|
);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
function renderRecorderInstallStatus(ctx, progress = {}) {
|
|
145
|
+
const percent = Math.max(0, Math.min(100, progress.percent || 0));
|
|
146
|
+
const state = {
|
|
147
|
+
downloading: "Downloading Windows recorder",
|
|
148
|
+
decompressing: "Unpacking ffmpeg",
|
|
149
|
+
probing: "Checking ffmpeg",
|
|
150
|
+
done: "Windows recorder ready",
|
|
151
|
+
}[progress.state] || "Preparing Windows recorder";
|
|
152
|
+
const attempt = progress.attempts > 1 ? `${progress.attempt} of ${progress.attempts}` : "single pass";
|
|
153
|
+
|
|
154
|
+
setDialog(ctx, "xlarge", () =>
|
|
155
|
+
ctx.api.ui.DialogAlert({
|
|
156
|
+
title: "Installing Windows recorder",
|
|
157
|
+
message: [
|
|
158
|
+
"ffmpeg DirectShow",
|
|
159
|
+
"",
|
|
160
|
+
state,
|
|
161
|
+
progressLine(percent),
|
|
162
|
+
"",
|
|
163
|
+
progress.total ? `${formatBytes(progress.downloaded || 0)} of ${formatBytes(progress.total)}` : "Fetching recorder binary",
|
|
164
|
+
`Attempt ${attempt}`,
|
|
165
|
+
"This is downloaded once into the managed opencode-voice cache.",
|
|
166
|
+
].join("\n"),
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
function modelStatus(model, options, settings) {
|
|
145
172
|
if (!model.implemented) return "planned";
|
|
146
173
|
if (isModelFilePresent(model, options, settings) && !isModelDownloaded(model, options, settings)) return "needs verification";
|
|
@@ -216,6 +243,30 @@ async function ensureEngineReady(ctx, settings) {
|
|
|
216
243
|
return true;
|
|
217
244
|
}
|
|
218
245
|
|
|
246
|
+
async function ensureRecorderReady(ctx, settings) {
|
|
247
|
+
if (process.platform !== "win32") return true;
|
|
248
|
+
|
|
249
|
+
const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir, skipFfmpegStaticInstall: true };
|
|
250
|
+
const current = getRecorderStatus(commandOptions, settings);
|
|
251
|
+
if (current.resolvedBinary) {
|
|
252
|
+
const probe = await probeRecorder(current.resolvedBinary);
|
|
253
|
+
if (probe.ok) return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
renderRecorderInstallStatus(ctx, { state: "downloading", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts: 5 });
|
|
257
|
+
toast(ctx.api, "Installing local Windows recorder...");
|
|
258
|
+
await ensureManagedRecorder(commandOptions, settings, {
|
|
259
|
+
retries: 5,
|
|
260
|
+
onProgress: (progress) => renderRecorderInstallStatus(ctx, progress),
|
|
261
|
+
onRetry: ({ error, nextAttempt, attempts }) => {
|
|
262
|
+
renderRecorderInstallStatus(ctx, { state: "downloading", downloaded: 0, total: 0, percent: 0, attempt: nextAttempt, attempts });
|
|
263
|
+
toast(ctx.api, `Recorder install retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
toast(ctx.api, "Windows recorder installed", "success");
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
219
270
|
function showModelPicker(ctx, firstRun = false) {
|
|
220
271
|
const settings = readSettings(ctx.api.kv);
|
|
221
272
|
setDialog(ctx, "large", () =>
|
|
@@ -325,8 +376,9 @@ function showLanguagePicker(ctx) {
|
|
|
325
376
|
|
|
326
377
|
function showMicrophonePicker(ctx) {
|
|
327
378
|
const settings = readSettings(ctx.api.kv);
|
|
379
|
+
const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir, skipFfmpegStaticInstall: true };
|
|
328
380
|
const placeholder = process.platform === "win32" ? "default, audio=default, \"Microphone (Name)\"" : "default, hw:0,0, pulse, :0, ...";
|
|
329
|
-
const devices = listMicrophones();
|
|
381
|
+
const devices = listMicrophones(commandOptions);
|
|
330
382
|
setDialog(ctx, "large", () =>
|
|
331
383
|
ctx.api.ui.DialogSelect({
|
|
332
384
|
title: "Voice microphone",
|
|
@@ -360,15 +412,20 @@ function showMicrophonePicker(ctx) {
|
|
|
360
412
|
function showDiagnostics(ctx) {
|
|
361
413
|
const settings = readSettings(ctx.api.kv);
|
|
362
414
|
const model = getModel(settings.model);
|
|
363
|
-
const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir };
|
|
415
|
+
const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir, skipFfmpegStaticInstall: true };
|
|
364
416
|
const whisperCli = resolveCommand("whisper-cli", commandOptions);
|
|
365
417
|
const ffmpeg = resolveCommand("ffmpeg", commandOptions);
|
|
366
418
|
const arecord = resolveCommand("arecord", commandOptions);
|
|
367
419
|
const sox = resolveCommand("sox", commandOptions);
|
|
368
420
|
const engine = getEngineStatus("whisper.cpp", ctx.options, settings);
|
|
421
|
+
const recorder = getRecorderStatus(commandOptions, settings);
|
|
369
422
|
const lines = [
|
|
370
423
|
`Platform: ${process.platform}-${process.arch}`,
|
|
371
424
|
`Recorder: ffmpeg=${ffmpeg ? "yes" : "no"}${ffmpeg ? ` (${ffmpeg})` : ""}, arecord=${arecord ? "yes" : "no"}, sox=${sox ? "yes" : "no"}`,
|
|
425
|
+
`Recorder source: ${recorder.source}`,
|
|
426
|
+
`Managed recorder dir: ${recorder.managedDir}`,
|
|
427
|
+
`Managed recorder installed: ${recorder.managedInstalled ? "yes" : "no"}`,
|
|
428
|
+
`Managed recorder version: ${recorder.manifest?.version || "missing"}`,
|
|
372
429
|
`Engine: ${engine.id}`,
|
|
373
430
|
`Engine source: ${engine.source}`,
|
|
374
431
|
`whisper-cli: ${whisperCli || "missing"}`,
|
|
@@ -665,8 +722,9 @@ async function startVoice(ctx, submit = false, hold = false) {
|
|
|
665
722
|
|
|
666
723
|
try {
|
|
667
724
|
await ensureEngineReady(ctx, settings);
|
|
725
|
+
await ensureRecorderReady(ctx, settings);
|
|
668
726
|
} catch (error) {
|
|
669
|
-
showError(ctx, "
|
|
727
|
+
showError(ctx, "Voice runtime setup failed", error);
|
|
670
728
|
return;
|
|
671
729
|
}
|
|
672
730
|
|
package/lib/engine.js
CHANGED
|
@@ -4,17 +4,48 @@ import path from "node:path";
|
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { createGunzip } from "node:zlib";
|
|
8
|
+
import { Readable, Transform } from "node:stream";
|
|
9
|
+
import { pipeline } from "node:stream/promises";
|
|
10
|
+
import { ensureDir, replaceFile, sha256 } from "./download.js";
|
|
11
|
+
import { getAudioDir, getCacheDir, getEnginesDir, getModelPath } from "./models.js";
|
|
9
12
|
|
|
10
13
|
const require = createRequire(import.meta.url);
|
|
11
14
|
const FFMPEG_STATIC_PACKAGE = "ffmpeg-static@^5.2.0";
|
|
15
|
+
const FFMPEG_STATIC_RELEASE = "b6.1.1";
|
|
16
|
+
const FFMPEG_STATIC_BASE_URL = "https://github.com/eugeneware/ffmpeg-static/releases/download";
|
|
17
|
+
const DEFAULT_RECORDER_DOWNLOAD_RETRIES = 5;
|
|
12
18
|
const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
19
|
let ffmpegStaticPathCache;
|
|
14
20
|
let ffmpegStaticInstallAttempted = false;
|
|
15
21
|
|
|
16
22
|
const RECORDING_MIN_BYTES = 44;
|
|
17
23
|
|
|
24
|
+
function sleep(ms) {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeArch(value) {
|
|
29
|
+
if (value === "amd64") return "x64";
|
|
30
|
+
if (value === "x86") return "ia32";
|
|
31
|
+
if (value === "aarch64") return "arm64";
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function windowsFfmpegPlatform(options = {}) {
|
|
36
|
+
const platform = options.platform || process.platform;
|
|
37
|
+
const arch = normalizeArch(options.arch || process.arch);
|
|
38
|
+
return { platform, arch, key: `${platform}-${arch}` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isWindows(options = {}) {
|
|
42
|
+
return (options.platform || process.platform) === "win32";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withDownloadDir(options = {}, settings = {}) {
|
|
46
|
+
return { ...options, downloadDir: settings.downloadDir || options.downloadDir };
|
|
47
|
+
}
|
|
48
|
+
|
|
18
49
|
function isExecutable(file) {
|
|
19
50
|
try {
|
|
20
51
|
fs.accessSync(file, fs.constants.X_OK);
|
|
@@ -24,11 +55,354 @@ function isExecutable(file) {
|
|
|
24
55
|
}
|
|
25
56
|
}
|
|
26
57
|
|
|
27
|
-
function
|
|
58
|
+
function executableExtensions(options = {}) {
|
|
59
|
+
if (!isWindows(options)) return [""];
|
|
60
|
+
return (process.env.PATHEXT || ".EXE;.CMD;.BAT")
|
|
61
|
+
.split(";")
|
|
62
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.filter((entry) => entry.startsWith("."));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveExecutable(file, options = {}) {
|
|
68
|
+
const normalized = path.resolve(file);
|
|
69
|
+
if (isExecutable(normalized)) return normalized;
|
|
70
|
+
|
|
71
|
+
if (path.extname(normalized) || !isWindows(options)) {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const ext of executableExtensions(options)) {
|
|
76
|
+
const candidate = `${normalized}${ext}`;
|
|
77
|
+
if (isExecutable(candidate)) return candidate;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function managedFfmpegDir(options = {}, settings = {}) {
|
|
84
|
+
const platform = windowsFfmpegPlatform(options);
|
|
85
|
+
return path.join(getCacheDir(options, settings), "recorders", "ffmpeg-static", platform.key);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getManagedRecorderBinary(options = {}, settings = {}) {
|
|
89
|
+
const platform = windowsFfmpegPlatform(options);
|
|
90
|
+
return path.join(managedFfmpegDir(options, settings), platform.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getManagedRecorderManifestPath(options = {}, settings = {}) {
|
|
94
|
+
return path.join(managedFfmpegDir(options, settings), "manifest.json");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function readManagedRecorderManifest(options = {}, settings = {}) {
|
|
98
|
+
const file = getManagedRecorderManifestPath(options, settings);
|
|
99
|
+
if (!fs.existsSync(file)) return null;
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function temporaryBinaryPath(binaryPath) {
|
|
108
|
+
const extension = path.extname(binaryPath);
|
|
109
|
+
if (!extension) return `${binaryPath}.tmp-${process.pid}`;
|
|
110
|
+
return path.join(path.dirname(binaryPath), `${path.basename(binaryPath, extension)}.tmp-${process.pid}${extension}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function selectManagedRecorderAsset(options = {}) {
|
|
114
|
+
const platform = windowsFfmpegPlatform(options);
|
|
115
|
+
if (platform.platform !== "win32") throw new Error(`Managed ffmpeg recorder is only available for Windows, got ${platform.key}`);
|
|
116
|
+
|
|
117
|
+
const assetArch = platform.arch === "arm64" ? "x64" : platform.arch;
|
|
118
|
+
if (!new Set(["x64", "ia32"]).has(assetArch)) throw new Error(`No managed ffmpeg recorder asset for ${platform.key}`);
|
|
119
|
+
|
|
120
|
+
const assetKey = `${platform.platform}-${assetArch}`;
|
|
121
|
+
return {
|
|
122
|
+
platform,
|
|
123
|
+
assetKey,
|
|
124
|
+
emulated: assetArch !== platform.arch,
|
|
125
|
+
url: `${FFMPEG_STATIC_BASE_URL}/${FFMPEG_STATIC_RELEASE}/ffmpeg-${assetKey}.gz`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function recorderSource(resolved, managedBinary) {
|
|
130
|
+
if (!resolved) return "missing";
|
|
131
|
+
if (path.resolve(resolved) === path.resolve(managedBinary)) return "managed";
|
|
132
|
+
return "system-or-bundled";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function recorderDownloadTimeoutMs(options = {}, hooks = {}) {
|
|
136
|
+
return Number(options.recorderDownloadTimeoutMs || options.downloadTimeoutMs || hooks.timeoutMs || 120000);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function fetchRecorderWithTimeout(url, timeoutMs) {
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
let timer;
|
|
142
|
+
const reset = () => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
145
|
+
};
|
|
146
|
+
const clear = () => clearTimeout(timer);
|
|
147
|
+
reset();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
151
|
+
return { response, signal: controller.signal, reset, clear };
|
|
152
|
+
} catch (error) {
|
|
153
|
+
clear();
|
|
154
|
+
if (error?.name === "AbortError") throw new Error(`Recorder download timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function recorderDownloadError(error, url, timeoutMs) {
|
|
160
|
+
if (error?.name === "AbortError" || error?.code === "ABORT_ERR") {
|
|
161
|
+
return new Error(`Recorder download timed out or stalled from ${url} after ${Math.round(timeoutMs / 1000)}s`);
|
|
162
|
+
}
|
|
163
|
+
return error;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function downloadRecorderAsset(sourceUrl, compressedFile, hooks = {}, attempt = 1, attempts = 1, options = {}) {
|
|
167
|
+
if (sourceUrl.startsWith("file://") || !/^https?:\/\//.test(sourceUrl)) {
|
|
168
|
+
const source = sourceUrl.startsWith("file://") ? new URL(sourceUrl) : path.resolve(sourceUrl);
|
|
169
|
+
const stat = await fs.promises.stat(source);
|
|
170
|
+
let downloaded = 0;
|
|
171
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: stat.size, percent: 0, attempt, attempts });
|
|
172
|
+
const progress = new Transform({
|
|
173
|
+
transform(chunk, _encoding, callback) {
|
|
174
|
+
downloaded += chunk.length ?? chunk.byteLength ?? 0;
|
|
175
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: stat.size, percent: stat.size ? (downloaded / stat.size) * 100 : 0, attempt, attempts });
|
|
176
|
+
callback(null, chunk);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
await pipeline(fs.createReadStream(source), progress, fs.createWriteStream(compressedFile));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const timeoutMs = recorderDownloadTimeoutMs(options, hooks);
|
|
184
|
+
const request = await fetchRecorderWithTimeout(sourceUrl, timeoutMs);
|
|
185
|
+
const response = request.response;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
if (!response.ok) throw new Error(`Recorder download failed from ${sourceUrl}: HTTP ${response.status}`);
|
|
189
|
+
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
190
|
+
let downloaded = 0;
|
|
191
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: contentLength, percent: 0, attempt, attempts });
|
|
192
|
+
|
|
193
|
+
const body = response.body?.getReader ? Readable.fromWeb(response.body) : response.body;
|
|
194
|
+
if (!body) throw new Error("Recorder download failed: empty response body");
|
|
195
|
+
|
|
196
|
+
const progress = new Transform({
|
|
197
|
+
transform(chunk, _encoding, callback) {
|
|
198
|
+
request.reset();
|
|
199
|
+
downloaded += chunk.length ?? chunk.byteLength ?? 0;
|
|
200
|
+
hooks.onProgress?.({ state: "downloading", downloaded, total: contentLength, percent: contentLength ? (downloaded / contentLength) * 100 : 0, attempt, attempts });
|
|
201
|
+
callback(null, chunk);
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await pipeline(body, progress, fs.createWriteStream(compressedFile), { signal: request.signal });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw recorderDownloadError(error, sourceUrl, timeoutMs);
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
request.clear();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function probeRecorder(binaryPath, options = {}) {
|
|
216
|
+
if (!binaryPath) return { ok: false, message: "missing binary" };
|
|
217
|
+
|
|
218
|
+
return new Promise((resolve) => {
|
|
219
|
+
let output = "";
|
|
220
|
+
let settled = false;
|
|
221
|
+
let timer;
|
|
222
|
+
const finish = (value) => {
|
|
223
|
+
if (settled) return;
|
|
224
|
+
settled = true;
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
resolve(value);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let proc;
|
|
230
|
+
try {
|
|
231
|
+
proc = spawn(binaryPath, ["-version"], {
|
|
232
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
233
|
+
env: {
|
|
234
|
+
...process.env,
|
|
235
|
+
PATH: [path.dirname(binaryPath), process.env.PATH].filter(Boolean).join(path.delimiter),
|
|
236
|
+
...options.env,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
finish({ ok: false, message: error instanceof Error ? error.message : String(error) });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
timer = setTimeout(() => {
|
|
245
|
+
try {
|
|
246
|
+
proc.kill("SIGKILL");
|
|
247
|
+
} catch {}
|
|
248
|
+
finish({ ok: false, message: "probe timed out" });
|
|
249
|
+
}, options.timeoutMs || 10000);
|
|
250
|
+
|
|
251
|
+
proc.stdout.on("data", (chunk) => {
|
|
252
|
+
output += chunk.toString();
|
|
253
|
+
});
|
|
254
|
+
proc.stderr.on("data", (chunk) => {
|
|
255
|
+
output += chunk.toString();
|
|
256
|
+
});
|
|
257
|
+
proc.on("error", (error) => finish({ ok: false, message: error.message }));
|
|
258
|
+
proc.on("exit", (code) => {
|
|
259
|
+
const versionLine = output.split(/\r?\n/).filter(Boolean)[0] || "";
|
|
260
|
+
const ok = code === 0 && /ffmpeg version/i.test(output);
|
|
261
|
+
finish({ ok, message: ok ? "ok" : `unexpected ffmpeg probe output${typeof code === "number" ? ` (exit ${code})` : ""}`, versionLine });
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function writeManagedRecorderManifest(managedBinary, source, options = {}, settings = {}) {
|
|
267
|
+
const hash = await sha256(managedBinary);
|
|
268
|
+
const stat = await fs.promises.stat(managedBinary);
|
|
269
|
+
const manifest = {
|
|
270
|
+
schema: "opencode-voice.recorder-install.v1",
|
|
271
|
+
id: "ffmpeg",
|
|
272
|
+
kind: "cli",
|
|
273
|
+
command: "ffmpeg",
|
|
274
|
+
platform: windowsFfmpegPlatform(options).key,
|
|
275
|
+
version: FFMPEG_STATIC_RELEASE,
|
|
276
|
+
source,
|
|
277
|
+
files: [{ path: path.basename(managedBinary), sha256: hash, size: stat.size }],
|
|
278
|
+
installedAt: new Date().toISOString(),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
await fs.promises.writeFile(getManagedRecorderManifestPath(options, settings), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
282
|
+
return manifest;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function getRecorderStatus(options = {}, settings = {}) {
|
|
286
|
+
const commandOptions = withDownloadDir(options, settings);
|
|
287
|
+
const managedDir = managedFfmpegDir(options, settings);
|
|
288
|
+
const managedBinary = getManagedRecorderBinary(options, settings);
|
|
289
|
+
const manifest = readManagedRecorderManifest(options, settings);
|
|
290
|
+
const resolvedBinary = resolveCommand("ffmpeg", commandOptions);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
id: "ffmpeg",
|
|
294
|
+
command: "ffmpeg",
|
|
295
|
+
platform: windowsFfmpegPlatform(options).key,
|
|
296
|
+
supported: isWindows(options),
|
|
297
|
+
managedDir,
|
|
298
|
+
managedBinary,
|
|
299
|
+
managedInstalled: isExecutable(managedBinary),
|
|
300
|
+
manifest,
|
|
301
|
+
resolvedBinary,
|
|
302
|
+
source: recorderSource(resolvedBinary, managedBinary),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function installManagedRecorder(options = {}, settings = {}, hooks = {}) {
|
|
307
|
+
const commandOptions = withDownloadDir(options, settings);
|
|
308
|
+
const asset = selectManagedRecorderAsset(commandOptions);
|
|
309
|
+
const managedDir = managedFfmpegDir(commandOptions, settings);
|
|
310
|
+
const managedBinary = getManagedRecorderBinary(commandOptions, settings);
|
|
311
|
+
if (!hooks.force && fs.existsSync(managedBinary)) {
|
|
312
|
+
const probe = await probeRecorder(managedBinary);
|
|
313
|
+
if (probe.ok) return { manifest: readManagedRecorderManifest(commandOptions, settings), managedBinary, probe, skipped: true };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await ensureDir(managedDir);
|
|
317
|
+
const compressedFile = path.join(managedDir, `${path.basename(asset.url)}.download`);
|
|
318
|
+
const tmpBinary = temporaryBinaryPath(managedBinary);
|
|
319
|
+
const attempts = Number(options.recorderDownloadRetries || hooks.retries || DEFAULT_RECORDER_DOWNLOAD_RETRIES);
|
|
320
|
+
let lastError;
|
|
321
|
+
|
|
322
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
323
|
+
try {
|
|
324
|
+
await fs.promises.unlink(compressedFile).catch(() => {});
|
|
325
|
+
await fs.promises.unlink(tmpBinary).catch(() => {});
|
|
326
|
+
await downloadRecorderAsset(asset.url, compressedFile, hooks, attempt, attempts, commandOptions);
|
|
327
|
+
|
|
328
|
+
const compressedSize = fs.existsSync(compressedFile) ? fs.statSync(compressedFile).size : 0;
|
|
329
|
+
hooks.onProgress?.({ state: "decompressing", downloaded: compressedSize, total: compressedSize, percent: 100, attempt, attempts });
|
|
330
|
+
await pipeline(fs.createReadStream(compressedFile), createGunzip(), fs.createWriteStream(tmpBinary));
|
|
331
|
+
await fs.promises.chmod(tmpBinary, 0o755);
|
|
332
|
+
|
|
333
|
+
hooks.onProgress?.({ state: "probing", downloaded: fs.statSync(tmpBinary).size, total: fs.statSync(tmpBinary).size, percent: 100, attempt, attempts });
|
|
334
|
+
const probe = await probeRecorder(tmpBinary);
|
|
335
|
+
if (!probe.ok) throw new Error(`Recorder probe failed: ${probe.message}`);
|
|
336
|
+
|
|
337
|
+
await replaceFile(tmpBinary, managedBinary);
|
|
338
|
+
await fs.promises.unlink(compressedFile).catch(() => {});
|
|
339
|
+
const manifest = await writeManagedRecorderManifest(
|
|
340
|
+
managedBinary,
|
|
341
|
+
{
|
|
342
|
+
type: "ffmpeg-static-release",
|
|
343
|
+
release: FFMPEG_STATIC_RELEASE,
|
|
344
|
+
url: asset.url,
|
|
345
|
+
assetPlatform: asset.assetKey,
|
|
346
|
+
emulated: asset.emulated,
|
|
347
|
+
},
|
|
348
|
+
commandOptions,
|
|
349
|
+
settings,
|
|
350
|
+
);
|
|
351
|
+
const installedSize = fs.statSync(managedBinary).size;
|
|
352
|
+
hooks.onProgress?.({ state: "done", downloaded: installedSize, total: installedSize, percent: 100, attempt, attempts });
|
|
353
|
+
return { manifest, managedBinary, probe, skipped: false };
|
|
354
|
+
} catch (error) {
|
|
355
|
+
lastError = error;
|
|
356
|
+
await fs.promises.unlink(tmpBinary).catch(() => {});
|
|
357
|
+
if (attempt >= attempts) break;
|
|
358
|
+
hooks.onRetry?.({ error, attempt, attempts, nextAttempt: attempt + 1 });
|
|
359
|
+
await sleep(Math.min(1000 * attempt, 3000));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
throw lastError;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function ensureManagedRecorder(options = {}, settings = {}, hooks = {}) {
|
|
367
|
+
const commandOptions = withDownloadDir(options, settings);
|
|
368
|
+
if (!isWindows(commandOptions)) return { ...getRecorderStatus(commandOptions, settings), ok: true, skipped: true, unsupported: true };
|
|
369
|
+
|
|
370
|
+
const existingOptions = { ...commandOptions, skipFfmpegStaticInstall: true };
|
|
371
|
+
const current = getRecorderStatus(existingOptions, settings);
|
|
372
|
+
if (current.resolvedBinary) {
|
|
373
|
+
const probe = await probeRecorder(current.resolvedBinary);
|
|
374
|
+
if (probe.ok) return { ...current, ok: true, probe, skipped: true };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const installed = await installManagedRecorder(commandOptions, settings, hooks);
|
|
379
|
+
return { ...getRecorderStatus(commandOptions, settings), ...installed, ok: true };
|
|
380
|
+
} catch (error) {
|
|
381
|
+
const resolvedBinary = resolveRecorderFallback(existingOptions, settings);
|
|
382
|
+
if (resolvedBinary) {
|
|
383
|
+
const probe = await probeRecorder(resolvedBinary);
|
|
384
|
+
if (probe.ok) {
|
|
385
|
+
return {
|
|
386
|
+
...getRecorderStatus(commandOptions, settings),
|
|
387
|
+
resolvedBinary,
|
|
388
|
+
source: recorderSource(resolvedBinary, getManagedRecorderBinary(commandOptions, settings)),
|
|
389
|
+
ok: true,
|
|
390
|
+
fallback: true,
|
|
391
|
+
managedError: error instanceof Error ? error.message : String(error),
|
|
392
|
+
probe,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
throw new Error(`Windows ffmpeg recorder install failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function executableNames(command, options = {}) {
|
|
28
402
|
const names = [command];
|
|
29
403
|
if (path.extname(command)) return names;
|
|
30
404
|
|
|
31
|
-
if (
|
|
405
|
+
if (isWindows(options)) {
|
|
32
406
|
const extensions = (process.env.PATHEXT || ".EXE;.CMD;.BAT")
|
|
33
407
|
.split(";")
|
|
34
408
|
.map((item) => item.trim().toLowerCase())
|
|
@@ -39,19 +413,25 @@ function executableNames(command) {
|
|
|
39
413
|
return [...new Set(names)];
|
|
40
414
|
}
|
|
41
415
|
|
|
42
|
-
function bundledFfmpegPath() {
|
|
416
|
+
function bundledFfmpegPath(options = {}) {
|
|
43
417
|
if (process.platform !== "win32") return "";
|
|
44
418
|
if (ffmpegStaticPathCache !== undefined) return ffmpegStaticPathCache;
|
|
45
419
|
|
|
46
420
|
const resolveFromDependency = () => {
|
|
47
421
|
try {
|
|
48
422
|
const candidate = require("ffmpeg-static");
|
|
49
|
-
|
|
423
|
+
if (typeof candidate !== "string") return "";
|
|
424
|
+
return resolveExecutable(candidate);
|
|
50
425
|
} catch {
|
|
51
426
|
return "";
|
|
52
427
|
}
|
|
53
428
|
};
|
|
54
429
|
|
|
430
|
+
const resolveFromLocalModule = () => {
|
|
431
|
+
const fallback = path.join(PLUGIN_ROOT, "node_modules", "ffmpeg-static", "ffmpeg");
|
|
432
|
+
return resolveExecutable(fallback);
|
|
433
|
+
};
|
|
434
|
+
|
|
55
435
|
const installDependency = () => {
|
|
56
436
|
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
57
437
|
try {
|
|
@@ -66,13 +446,17 @@ function bundledFfmpegPath() {
|
|
|
66
446
|
};
|
|
67
447
|
|
|
68
448
|
let candidate = resolveFromDependency();
|
|
69
|
-
if (!candidate
|
|
449
|
+
if (!candidate) candidate = resolveFromLocalModule();
|
|
450
|
+
if (!candidate && !ffmpegStaticInstallAttempted && !options.skipFfmpegStaticInstall) {
|
|
70
451
|
ffmpegStaticInstallAttempted = true;
|
|
71
452
|
if (installDependency()) {
|
|
72
453
|
candidate = resolveFromDependency();
|
|
73
454
|
}
|
|
74
455
|
}
|
|
75
456
|
|
|
457
|
+
if (!candidate) candidate = resolveFromLocalModule();
|
|
458
|
+
if (!candidate && options.skipFfmpegStaticInstall) return "";
|
|
459
|
+
|
|
76
460
|
ffmpegStaticPathCache = candidate || "";
|
|
77
461
|
return ffmpegStaticPathCache;
|
|
78
462
|
}
|
|
@@ -99,16 +483,19 @@ function candidateCommands(command, options = {}) {
|
|
|
99
483
|
}
|
|
100
484
|
|
|
101
485
|
if (command === "ffmpeg") {
|
|
102
|
-
|
|
486
|
+
if (options.ffmpeg) candidates.push(options.ffmpeg);
|
|
487
|
+
if (process.env.OPENCODE_VOICE_FFMPEG) candidates.push(process.env.OPENCODE_VOICE_FFMPEG);
|
|
488
|
+
if (isWindows(options)) candidates.push(getManagedRecorderBinary(options, options));
|
|
489
|
+
const bundled = bundledFfmpegPath(options);
|
|
103
490
|
if (bundled) candidates.push(bundled);
|
|
104
491
|
}
|
|
105
492
|
|
|
106
493
|
const bundledDir = getBundledEngineDir(command, options);
|
|
107
494
|
if (bundledDir) {
|
|
108
|
-
for (const name of executableNames(command)) candidates.push(path.join(bundledDir, name));
|
|
495
|
+
for (const name of executableNames(command, options)) candidates.push(path.join(bundledDir, name));
|
|
109
496
|
}
|
|
110
497
|
|
|
111
|
-
candidates.push(...executableNames(command));
|
|
498
|
+
candidates.push(...executableNames(command, options));
|
|
112
499
|
|
|
113
500
|
const fallbackDirs = [
|
|
114
501
|
path.join(os.homedir(), ".local", "bin"),
|
|
@@ -119,13 +506,13 @@ function candidateCommands(command, options = {}) {
|
|
|
119
506
|
"/bin",
|
|
120
507
|
];
|
|
121
508
|
|
|
122
|
-
if (
|
|
509
|
+
if (isWindows(options)) {
|
|
123
510
|
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
124
511
|
fallbackDirs.unshift(path.join(localAppData, "opencode-voice", "bin"));
|
|
125
512
|
}
|
|
126
513
|
|
|
127
514
|
for (const dir of fallbackDirs) {
|
|
128
|
-
for (const name of executableNames(command)) candidates.push(path.join(dir, name));
|
|
515
|
+
for (const name of executableNames(command, options)) candidates.push(path.join(dir, name));
|
|
129
516
|
}
|
|
130
517
|
|
|
131
518
|
return [...new Set(candidates.filter(Boolean))];
|
|
@@ -134,13 +521,34 @@ function candidateCommands(command, options = {}) {
|
|
|
134
521
|
export function resolveCommand(command, options = {}) {
|
|
135
522
|
for (const candidate of candidateCommands(command, options)) {
|
|
136
523
|
if (looksLikePath(candidate)) {
|
|
137
|
-
|
|
524
|
+
const resolved = resolveExecutable(candidate, options);
|
|
525
|
+
if (resolved) return resolved;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
530
|
+
const file = path.join(dir, candidate);
|
|
531
|
+
const resolved = resolveExecutable(file, options);
|
|
532
|
+
if (resolved) return resolved;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function resolveRecorderFallback(options = {}, settings = {}) {
|
|
540
|
+
const managedBinary = path.resolve(getManagedRecorderBinary(options, settings));
|
|
541
|
+
for (const candidate of candidateCommands("ffmpeg", options)) {
|
|
542
|
+
if (looksLikePath(candidate)) {
|
|
543
|
+
const resolved = resolveExecutable(candidate, options);
|
|
544
|
+
if (resolved && path.resolve(resolved) !== managedBinary) return resolved;
|
|
138
545
|
continue;
|
|
139
546
|
}
|
|
140
547
|
|
|
141
548
|
for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
142
549
|
const file = path.join(dir, candidate);
|
|
143
|
-
|
|
550
|
+
const resolved = resolveExecutable(file, options);
|
|
551
|
+
if (resolved && path.resolve(resolved) !== managedBinary) return resolved;
|
|
144
552
|
}
|
|
145
553
|
}
|
|
146
554
|
|
|
@@ -181,9 +589,9 @@ function parseWindowsMicrophones(stderr) {
|
|
|
181
589
|
return [...devices].filter(Boolean);
|
|
182
590
|
}
|
|
183
591
|
|
|
184
|
-
export function listMicrophones() {
|
|
185
|
-
const arecordCommand = resolveCommand("arecord");
|
|
186
|
-
const ffmpegCommand = resolveCommand("ffmpeg");
|
|
592
|
+
export function listMicrophones(options = {}) {
|
|
593
|
+
const arecordCommand = resolveCommand("arecord", options);
|
|
594
|
+
const ffmpegCommand = resolveCommand("ffmpeg", options);
|
|
187
595
|
|
|
188
596
|
if (process.platform === "linux" && arecordCommand) {
|
|
189
597
|
const result = spawnSync(arecordCommand, ["-L"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -381,9 +789,15 @@ export class VoiceRuntime {
|
|
|
381
789
|
|
|
382
790
|
const dir = getAudioDir(this.options, settings);
|
|
383
791
|
await ensureDir(dir);
|
|
792
|
+
const recorderSettings = { ...this.options, ...settings, downloadDir: settings.downloadDir || this.options.downloadDir };
|
|
793
|
+
const recorderReady = await ensureManagedRecorder(recorderSettings, settings).catch((error) => {
|
|
794
|
+
if (isWindows(recorderSettings)) throw error;
|
|
795
|
+
return null;
|
|
796
|
+
});
|
|
797
|
+
if (recorderReady?.resolvedBinary) recorderSettings.ffmpeg = recorderReady.resolvedBinary;
|
|
384
798
|
|
|
385
799
|
const file = path.join(dir, `voice-${Date.now()}.wav`);
|
|
386
|
-
const recorders = buildRecorders(file,
|
|
800
|
+
const recorders = buildRecorders(file, recorderSettings);
|
|
387
801
|
const errors = [];
|
|
388
802
|
this.recordingError = "";
|
|
389
803
|
|
package/lib/engines.js
CHANGED
|
@@ -282,8 +282,7 @@ export async function installManagedEngine(engineId = ENGINE.id, options = {}, s
|
|
|
282
282
|
const probe = await probeEngine(engineId, tmpBinary);
|
|
283
283
|
if (!probe.ok) throw new Error(`Engine probe failed: ${probe.message}`);
|
|
284
284
|
|
|
285
|
-
await
|
|
286
|
-
await fs.promises.rename(tmpBinary, managedBinary);
|
|
285
|
+
await replaceFile(tmpBinary, managedBinary);
|
|
287
286
|
await fs.promises.unlink(compressedFile).catch(() => {});
|
|
288
287
|
const manifest = await writeManifest(
|
|
289
288
|
engineId,
|
|
@@ -332,8 +331,7 @@ export async function importManagedEngine(engineId = ENGINE.id, sourcePath, opti
|
|
|
332
331
|
throw new Error(`Imported binary failed probe: ${probe.message}`);
|
|
333
332
|
}
|
|
334
333
|
|
|
335
|
-
await
|
|
336
|
-
await fs.promises.rename(tmp, managedBinary);
|
|
334
|
+
await replaceFile(tmp, managedBinary);
|
|
337
335
|
const manifest = await writeManifest(engineId, managedBinary, { type: "local-file", path: source }, options);
|
|
338
336
|
return { manifest, managedBinary };
|
|
339
337
|
}
|