@hxnnxs/opencode-voice 0.1.0
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 +17 -0
- package/CONTRIBUTING.md +22 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +36 -0
- package/README.es.md +174 -0
- package/README.md +174 -0
- package/README.ru.md +174 -0
- package/README.zh.md +174 -0
- package/SECURITY.md +19 -0
- package/assets/opencode-voice-dark.svg +27 -0
- package/assets/opencode-voice-light.svg +27 -0
- package/bin/opencode-voice.js +179 -0
- package/index.js +819 -0
- package/lib/download.js +129 -0
- package/lib/engine.js +406 -0
- package/lib/engines.js +337 -0
- package/lib/models.js +161 -0
- package/package.json +70 -0
package/index.js
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
import { MODELS, DEFAULT_SETTINGS, PLUGIN_ID, formatSize, getCacheDir, getModel, getModelPath, isModelDownloaded, isModelFilePresent } from "./lib/models.js";
|
|
2
|
+
import { downloadModel } from "./lib/download.js";
|
|
3
|
+
import { VoiceRuntime, commandExists, listMicrophones, resolveCommand } from "./lib/engine.js";
|
|
4
|
+
import { getEngineStatus, importManagedEngine, installManagedEngine, probeEngine, removeManagedEngine } from "./lib/engines.js";
|
|
5
|
+
|
|
6
|
+
const KV = {
|
|
7
|
+
hotkey: "voice.hotkey",
|
|
8
|
+
toggleHotkey: "voice.toggleHotkey",
|
|
9
|
+
submitHotkey: "voice.submitHotkey",
|
|
10
|
+
model: "voice.model",
|
|
11
|
+
language: "voice.language",
|
|
12
|
+
mic: "voice.mic",
|
|
13
|
+
autoSubmit: "voice.autoSubmit",
|
|
14
|
+
downloadDir: "voice.downloadDir",
|
|
15
|
+
onboardingDone: "voice.onboardingDone",
|
|
16
|
+
setupSkipped: "voice.setupSkipped",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function readSettings(kv) {
|
|
20
|
+
const settings = { ...DEFAULT_SETTINGS };
|
|
21
|
+
for (const [name, key] of Object.entries(KV)) settings[name] = kv.get(key, settings[name]);
|
|
22
|
+
|
|
23
|
+
if (!getModel(settings.model)?.implemented) settings.model = DEFAULT_SETTINGS.model;
|
|
24
|
+
settings.hotkey = String(settings.hotkey || "").trim();
|
|
25
|
+
settings.toggleHotkey = String(settings.toggleHotkey || "").trim();
|
|
26
|
+
settings.submitHotkey = String(settings.submitHotkey || "").trim();
|
|
27
|
+
settings.language = String(settings.language || "auto").trim() || "auto";
|
|
28
|
+
settings.mic = String(settings.mic || "").trim();
|
|
29
|
+
settings.downloadDir = String(settings.downloadDir || "").trim();
|
|
30
|
+
settings.autoSubmit = Boolean(settings.autoSubmit);
|
|
31
|
+
settings.onboardingDone = Boolean(settings.onboardingDone);
|
|
32
|
+
settings.setupSkipped = Boolean(settings.setupSkipped);
|
|
33
|
+
return settings;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeSetting(kv, name, value) {
|
|
37
|
+
kv.set(KV[name], value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function migrateSettings(kv) {
|
|
41
|
+
// Early builds defaulted the hold-to-talk key to space, but terminal release
|
|
42
|
+
// events are too inconsistent. Keep ctrl+r as the reliable toggle key.
|
|
43
|
+
const holdHotkey = kv.get(KV.hotkey, undefined);
|
|
44
|
+
if (holdHotkey === "space" || holdHotkey === "ctrl+r") kv.set(KV.hotkey, DEFAULT_SETTINGS.hotkey);
|
|
45
|
+
if (!kv.get(KV.toggleHotkey, undefined)) kv.set(KV.toggleHotkey, DEFAULT_SETTINGS.toggleHotkey);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toast(api, message, variant = "info") {
|
|
49
|
+
api.ui.toast({ title: "Voice", message, variant });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setDialog(ctx, size, render) {
|
|
53
|
+
ctx.api.ui.dialog.setSize(size);
|
|
54
|
+
ctx.api.ui.dialog.replace(render);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatBytes(value) {
|
|
58
|
+
if (!value || value < 0) return "0 MB";
|
|
59
|
+
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
60
|
+
return `${Math.max(1, Math.round(value / 1024 / 1024))} MB`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatRate(bytesPerSecond) {
|
|
64
|
+
if (!bytesPerSecond || bytesPerSecond < 1) return "warming up";
|
|
65
|
+
if (bytesPerSecond >= 1024 * 1024) return `${(bytesPerSecond / 1024 / 1024).toFixed(1)} MB/s`;
|
|
66
|
+
return `${Math.max(1, Math.round(bytesPerSecond / 1024))} KB/s`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatDuration(seconds) {
|
|
70
|
+
if (!Number.isFinite(seconds) || seconds < 1) return "calculating";
|
|
71
|
+
const total = Math.ceil(seconds);
|
|
72
|
+
const minutes = Math.floor(total / 60);
|
|
73
|
+
const rest = total % 60;
|
|
74
|
+
if (!minutes) return `${rest}s`;
|
|
75
|
+
return `${minutes}m ${String(rest).padStart(2, "0")}s`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function progressBar(percent) {
|
|
79
|
+
const total = 34;
|
|
80
|
+
const filled = Math.max(0, Math.min(total, Math.round((percent / 100) * total)));
|
|
81
|
+
return `${"█".repeat(filled)}${"░".repeat(total - filled)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function progressLine(percent) {
|
|
85
|
+
return `${progressBar(percent)} ${String(Math.round(percent)).padStart(3, " ")}%`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderDownloadStatus(ctx, model, progress = {}) {
|
|
89
|
+
const percent = Math.max(0, Math.min(100, progress.percent || 0));
|
|
90
|
+
const state = progress.state === "verifying" ? "Verifying checksum" : progress.state === "done" ? "Ready" : "Downloading model";
|
|
91
|
+
const downloaded = progress.downloaded || 0;
|
|
92
|
+
const total = progress.total || (model.sizeMB ? model.sizeMB * 1024 * 1024 : 0);
|
|
93
|
+
const remaining = total && progress.speedBps ? (total - downloaded) / progress.speedBps : Number.NaN;
|
|
94
|
+
const attempt = progress.attempts > 1 ? `${progress.attempt} of ${progress.attempts}` : "single pass";
|
|
95
|
+
|
|
96
|
+
setDialog(ctx, "xlarge", () =>
|
|
97
|
+
ctx.api.ui.DialogAlert({
|
|
98
|
+
title: "Downloading voice model",
|
|
99
|
+
message: [
|
|
100
|
+
model.name,
|
|
101
|
+
"",
|
|
102
|
+
state,
|
|
103
|
+
progressLine(percent),
|
|
104
|
+
"",
|
|
105
|
+
`${formatBytes(downloaded)} of ${formatBytes(total)}`,
|
|
106
|
+
`${formatRate(progress.speedBps)} · ETA ${formatDuration(remaining)}`,
|
|
107
|
+
`Attempt ${attempt}`,
|
|
108
|
+
progress.state === "verifying" ? "Verifying SHA256 before activating the model." : "Interrupted downloads resume automatically.",
|
|
109
|
+
].join("\n"),
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderEngineInstallStatus(ctx, progress = {}) {
|
|
115
|
+
const percent = Math.max(0, Math.min(100, progress.percent || 0));
|
|
116
|
+
const state = {
|
|
117
|
+
registry: "Loading engine registry",
|
|
118
|
+
downloading: "Downloading whisper.cpp engine",
|
|
119
|
+
verifying: "Verifying engine archive",
|
|
120
|
+
decompressing: "Unpacking engine",
|
|
121
|
+
"verifying-binary": "Verifying engine binary",
|
|
122
|
+
probing: "Checking whisper-cli",
|
|
123
|
+
done: "Native engine ready",
|
|
124
|
+
}[progress.state] || "Preparing native engine";
|
|
125
|
+
const attempt = progress.attempts > 1 ? `${progress.attempt} of ${progress.attempts}` : "single pass";
|
|
126
|
+
|
|
127
|
+
setDialog(ctx, "xlarge", () =>
|
|
128
|
+
ctx.api.ui.DialogAlert({
|
|
129
|
+
title: "Installing voice engine",
|
|
130
|
+
message: [
|
|
131
|
+
"whisper.cpp",
|
|
132
|
+
"",
|
|
133
|
+
state,
|
|
134
|
+
progressLine(percent),
|
|
135
|
+
"",
|
|
136
|
+
progress.total ? `${formatBytes(progress.downloaded || 0)} of ${formatBytes(progress.total)}` : "Fetching release metadata",
|
|
137
|
+
`Attempt ${attempt}`,
|
|
138
|
+
"This is downloaded once into the managed opencode-voice cache.",
|
|
139
|
+
].join("\n"),
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function modelStatus(model, options, settings) {
|
|
145
|
+
if (!model.implemented) return "planned";
|
|
146
|
+
if (isModelFilePresent(model, options, settings) && !isModelDownloaded(model, options, settings)) return "needs verification";
|
|
147
|
+
return isModelDownloaded(model, options, settings) ? "downloaded" : "not downloaded";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function modelOptions(options, settings) {
|
|
151
|
+
return MODELS.map((model) => ({
|
|
152
|
+
title: `${model.implemented && isModelDownloaded(model, options, settings) ? "[downloaded]" : model.implemented ? "[download]" : "[planned]"} ${model.name} - ${formatSize(model)}`,
|
|
153
|
+
value: model.id,
|
|
154
|
+
category: model.implemented ? "Available now" : "Planned sidecar models",
|
|
155
|
+
disabled: !model.implemented,
|
|
156
|
+
truncateTitle: false,
|
|
157
|
+
details: [`${model.engine} - ${model.languages} - ${modelStatus(model, options, settings)}`, model.description],
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function ensureDownloaded(ctx, model, settings) {
|
|
162
|
+
if (isModelDownloaded(model, ctx.options, settings)) return true;
|
|
163
|
+
|
|
164
|
+
const startedAt = Date.now();
|
|
165
|
+
let lastRender = 0;
|
|
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: 3, speedBps });
|
|
168
|
+
|
|
169
|
+
toast(ctx.api, `Downloading ${model.name}...`);
|
|
170
|
+
await downloadModel(model, ctx.options, settings, {
|
|
171
|
+
retries: 3,
|
|
172
|
+
onProgress: (progress) => {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
const elapsed = Math.max(1, (now - startedAt) / 1000);
|
|
175
|
+
speedBps = progress.downloaded ? progress.downloaded / elapsed : speedBps;
|
|
176
|
+
if (progress.state !== "done" && progress.state !== "verifying" && now - lastRender < 350) return;
|
|
177
|
+
lastRender = now;
|
|
178
|
+
renderDownloadStatus(ctx, model, { ...progress, speedBps });
|
|
179
|
+
},
|
|
180
|
+
onRetry: ({ error, nextAttempt, attempts }) => {
|
|
181
|
+
renderDownloadStatus(ctx, model, {
|
|
182
|
+
state: "downloading",
|
|
183
|
+
downloaded: 0,
|
|
184
|
+
total: model.sizeMB ? model.sizeMB * 1024 * 1024 : 0,
|
|
185
|
+
percent: 0,
|
|
186
|
+
attempt: nextAttempt,
|
|
187
|
+
attempts,
|
|
188
|
+
speedBps: 0,
|
|
189
|
+
});
|
|
190
|
+
toast(ctx.api, `Download retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
toast(ctx.api, `${model.name} downloaded`, "success");
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function ensureEngineReady(ctx, settings) {
|
|
198
|
+
const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir };
|
|
199
|
+
const current = getEngineStatus("whisper.cpp", ctx.options, settings);
|
|
200
|
+
if (current.resolvedBinary) {
|
|
201
|
+
const probe = await probeEngine("whisper.cpp", current.resolvedBinary);
|
|
202
|
+
if (probe.ok) return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
renderEngineInstallStatus(ctx, { state: "registry", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts: 3 });
|
|
206
|
+
toast(ctx.api, "Installing local voice engine...");
|
|
207
|
+
await installManagedEngine("whisper.cpp", commandOptions, settings, {
|
|
208
|
+
retries: 3,
|
|
209
|
+
onProgress: (progress) => renderEngineInstallStatus(ctx, progress),
|
|
210
|
+
onRetry: ({ error, nextAttempt, attempts }) => {
|
|
211
|
+
renderEngineInstallStatus(ctx, { state: "downloading", downloaded: 0, total: 0, percent: 0, attempt: nextAttempt, attempts });
|
|
212
|
+
toast(ctx.api, `Engine install retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
toast(ctx.api, "Voice engine installed", "success");
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function showModelPicker(ctx, firstRun = false) {
|
|
220
|
+
const settings = readSettings(ctx.api.kv);
|
|
221
|
+
setDialog(ctx, "large", () =>
|
|
222
|
+
ctx.api.ui.DialogSelect({
|
|
223
|
+
title: firstRun ? "Set up voice input: choose a local model" : "Voice model",
|
|
224
|
+
placeholder: "Search voice models...",
|
|
225
|
+
current: settings.model,
|
|
226
|
+
options: [
|
|
227
|
+
...modelOptions(ctx.options, settings),
|
|
228
|
+
...(firstRun
|
|
229
|
+
? [
|
|
230
|
+
{
|
|
231
|
+
title: "Skip setup for now",
|
|
232
|
+
value: "__skip",
|
|
233
|
+
category: "Setup",
|
|
234
|
+
description: "You can open this again with /voice-settings.",
|
|
235
|
+
},
|
|
236
|
+
]
|
|
237
|
+
: []),
|
|
238
|
+
],
|
|
239
|
+
onSelect: async (option) => {
|
|
240
|
+
if (option.value === "__skip") {
|
|
241
|
+
writeSetting(ctx.api.kv, "onboardingDone", true);
|
|
242
|
+
writeSetting(ctx.api.kv, "setupSkipped", true);
|
|
243
|
+
ctx.api.ui.dialog.clear();
|
|
244
|
+
toast(ctx.api, "Voice setup skipped. Use /voice-settings when ready.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const model = getModel(option.value);
|
|
249
|
+
if (!model?.implemented) return;
|
|
250
|
+
|
|
251
|
+
const nextSettings = { ...readSettings(ctx.api.kv), model: model.id };
|
|
252
|
+
writeSetting(ctx.api.kv, "model", model.id);
|
|
253
|
+
writeSetting(ctx.api.kv, "onboardingDone", true);
|
|
254
|
+
writeSetting(ctx.api.kv, "setupSkipped", false);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await ensureEngineReady(ctx, nextSettings);
|
|
258
|
+
await ensureDownloaded(ctx, model, nextSettings);
|
|
259
|
+
ctx.api.ui.dialog.clear();
|
|
260
|
+
} catch (error) {
|
|
261
|
+
showError(ctx, "Voice setup failed", error);
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function shouldShowStartupModelPicker(ctx) {
|
|
269
|
+
const settings = readSettings(ctx.api.kv);
|
|
270
|
+
const model = getModel(settings.model);
|
|
271
|
+
return !settings.onboardingDone || (!settings.setupSkipped && !isModelDownloaded(model, ctx.options, settings));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function showPrompt(ctx, input) {
|
|
275
|
+
setDialog(ctx, "medium", () =>
|
|
276
|
+
ctx.api.ui.DialogPrompt({
|
|
277
|
+
title: input.title,
|
|
278
|
+
placeholder: input.placeholder,
|
|
279
|
+
value: input.value,
|
|
280
|
+
onConfirm: (value) => {
|
|
281
|
+
input.onConfirm(value);
|
|
282
|
+
},
|
|
283
|
+
onCancel: () => showSettings(ctx),
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function showLanguagePicker(ctx) {
|
|
289
|
+
const settings = readSettings(ctx.api.kv);
|
|
290
|
+
const options = [
|
|
291
|
+
{ title: "Auto detect", value: "auto", description: "Let Whisper detect the language." },
|
|
292
|
+
{ title: "Russian", value: "ru" },
|
|
293
|
+
{ title: "English", value: "en" },
|
|
294
|
+
{ title: "German", value: "de" },
|
|
295
|
+
{ title: "Spanish", value: "es" },
|
|
296
|
+
{ title: "French", value: "fr" },
|
|
297
|
+
{ title: "Custom code", value: "__custom", description: "Enter a Whisper language code manually." },
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
setDialog(ctx, "medium", () =>
|
|
301
|
+
ctx.api.ui.DialogSelect({
|
|
302
|
+
title: "Voice language",
|
|
303
|
+
current: settings.language,
|
|
304
|
+
options,
|
|
305
|
+
onSelect: (option) => {
|
|
306
|
+
if (option.value === "__custom") {
|
|
307
|
+
showPrompt(ctx, {
|
|
308
|
+
title: "Custom language code",
|
|
309
|
+
placeholder: "ru, en, de, ...",
|
|
310
|
+
value: settings.language === "auto" ? "" : settings.language,
|
|
311
|
+
onConfirm: (value) => {
|
|
312
|
+
writeSetting(ctx.api.kv, "language", value.trim() || "auto");
|
|
313
|
+
showSettings(ctx);
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
writeSetting(ctx.api.kv, "language", option.value);
|
|
320
|
+
showSettings(ctx);
|
|
321
|
+
},
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function showMicrophonePicker(ctx) {
|
|
327
|
+
const settings = readSettings(ctx.api.kv);
|
|
328
|
+
const devices = listMicrophones();
|
|
329
|
+
setDialog(ctx, "large", () =>
|
|
330
|
+
ctx.api.ui.DialogSelect({
|
|
331
|
+
title: "Voice microphone",
|
|
332
|
+
current: settings.mic || "",
|
|
333
|
+
options: [
|
|
334
|
+
{ title: "System default", value: "", description: "Use the default input device." },
|
|
335
|
+
...devices.map((device) => ({ title: device, value: device })),
|
|
336
|
+
{ title: "Custom device", value: "__custom", description: "Enter ffmpeg/arecord device manually." },
|
|
337
|
+
],
|
|
338
|
+
onSelect: (option) => {
|
|
339
|
+
if (option.value === "__custom") {
|
|
340
|
+
showPrompt(ctx, {
|
|
341
|
+
title: "Custom microphone device",
|
|
342
|
+
placeholder: "default, hw:0,0, pulse, :0, ...",
|
|
343
|
+
value: settings.mic,
|
|
344
|
+
onConfirm: (value) => {
|
|
345
|
+
writeSetting(ctx.api.kv, "mic", value.trim());
|
|
346
|
+
showSettings(ctx);
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
writeSetting(ctx.api.kv, "mic", option.value);
|
|
353
|
+
showSettings(ctx);
|
|
354
|
+
},
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function showDiagnostics(ctx) {
|
|
360
|
+
const settings = readSettings(ctx.api.kv);
|
|
361
|
+
const model = getModel(settings.model);
|
|
362
|
+
const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir };
|
|
363
|
+
const whisperCli = resolveCommand("whisper-cli", commandOptions);
|
|
364
|
+
const engine = getEngineStatus("whisper.cpp", ctx.options, settings);
|
|
365
|
+
const lines = [
|
|
366
|
+
`Platform: ${process.platform}-${process.arch}`,
|
|
367
|
+
`Recorder: ffmpeg=${commandExists("ffmpeg") ? "yes" : "no"}, arecord=${commandExists("arecord") ? "yes" : "no"}, sox=${commandExists("sox") ? "yes" : "no"}`,
|
|
368
|
+
`Engine: ${engine.id}`,
|
|
369
|
+
`Engine source: ${engine.source}`,
|
|
370
|
+
`whisper-cli: ${whisperCli || "missing"}`,
|
|
371
|
+
`Managed engine dir: ${engine.managedDir}`,
|
|
372
|
+
`Managed installed: ${engine.managedInstalled ? "yes" : "no"}`,
|
|
373
|
+
`Managed version: ${engine.manifest?.version || "missing"}`,
|
|
374
|
+
`Active model: ${model.name}`,
|
|
375
|
+
`Model downloaded: ${isModelDownloaded(model, ctx.options, settings) ? "yes" : "no"}`,
|
|
376
|
+
`Model path: ${getModelPath(model, ctx.options, settings)}`,
|
|
377
|
+
`Cache dir: ${getCacheDir(ctx.options, settings)}`,
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
setDialog(ctx, "medium", () =>
|
|
381
|
+
ctx.api.ui.DialogAlert({
|
|
382
|
+
title: "Voice diagnostics",
|
|
383
|
+
message: lines.join("\n"),
|
|
384
|
+
onConfirm: () => showSettings(ctx),
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function showEngineManager(ctx) {
|
|
390
|
+
const settings = readSettings(ctx.api.kv);
|
|
391
|
+
const status = getEngineStatus("whisper.cpp", ctx.options, settings);
|
|
392
|
+
const canImport = Boolean(status.resolvedBinary && status.source !== "managed");
|
|
393
|
+
const options = [
|
|
394
|
+
{
|
|
395
|
+
title: "Use detected whisper-cli as managed engine",
|
|
396
|
+
value: "import",
|
|
397
|
+
description: canImport ? status.resolvedBinary : "No external whisper-cli detected",
|
|
398
|
+
disabled: !canImport,
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
title: "Install managed whisper.cpp",
|
|
402
|
+
value: "install",
|
|
403
|
+
description: "Download the matching native engine from GitHub Releases.",
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
title: "Remove managed engine",
|
|
407
|
+
value: "remove",
|
|
408
|
+
description: status.managedInstalled ? status.managedBinary : "No managed engine installed",
|
|
409
|
+
disabled: !status.managedInstalled,
|
|
410
|
+
},
|
|
411
|
+
{ title: "Diagnostics", value: "diagnostics", description: "Show recorder, model, and engine paths." },
|
|
412
|
+
{ title: "Back", value: "back" },
|
|
413
|
+
];
|
|
414
|
+
|
|
415
|
+
setDialog(ctx, "large", () =>
|
|
416
|
+
ctx.api.ui.DialogSelect({
|
|
417
|
+
title: "Native engine",
|
|
418
|
+
options,
|
|
419
|
+
footer: [
|
|
420
|
+
`Source: ${status.source}`,
|
|
421
|
+
`Resolved: ${status.resolvedBinary || "missing"}`,
|
|
422
|
+
`Managed: ${status.managedBinary}`,
|
|
423
|
+
].join("\n"),
|
|
424
|
+
onSelect: async (option) => {
|
|
425
|
+
if (option.value === "back") showSettings(ctx);
|
|
426
|
+
if (option.value === "diagnostics") showDiagnostics(ctx);
|
|
427
|
+
if (option.value === "import") {
|
|
428
|
+
try {
|
|
429
|
+
const result = await importManagedEngine("whisper.cpp", status.resolvedBinary, ctx.options, settings);
|
|
430
|
+
toast(ctx.api, `Managed engine imported: ${result.managedBinary}`, "success");
|
|
431
|
+
showEngineManager(ctx);
|
|
432
|
+
} catch (error) {
|
|
433
|
+
showError(ctx, "Engine import failed", error);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (option.value === "install") {
|
|
437
|
+
try {
|
|
438
|
+
await ensureEngineReady(ctx, settings);
|
|
439
|
+
showEngineManager(ctx);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
showError(ctx, "Engine install failed", error);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (option.value === "remove") {
|
|
445
|
+
try {
|
|
446
|
+
await removeManagedEngine("whisper.cpp", ctx.options, settings);
|
|
447
|
+
toast(ctx.api, "Managed engine removed");
|
|
448
|
+
showEngineManager(ctx);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
showError(ctx, "Engine remove failed", error);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function showError(ctx, title, error) {
|
|
459
|
+
setDialog(ctx, "medium", () =>
|
|
460
|
+
ctx.api.ui.DialogAlert({
|
|
461
|
+
title,
|
|
462
|
+
message: error instanceof Error ? error.message : String(error),
|
|
463
|
+
onConfirm: () => showSettings(ctx),
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function downloadActiveModel(ctx) {
|
|
469
|
+
const settings = readSettings(ctx.api.kv);
|
|
470
|
+
const model = getModel(settings.model);
|
|
471
|
+
try {
|
|
472
|
+
await ensureEngineReady(ctx, settings);
|
|
473
|
+
await ensureDownloaded(ctx, model, settings);
|
|
474
|
+
showSettings(ctx);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
showError(ctx, "Model download failed", error);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function showSettings(ctx) {
|
|
481
|
+
const settings = readSettings(ctx.api.kv);
|
|
482
|
+
const model = getModel(settings.model);
|
|
483
|
+
const downloaded = isModelDownloaded(model, ctx.options, settings);
|
|
484
|
+
|
|
485
|
+
setDialog(ctx, "large", () =>
|
|
486
|
+
ctx.api.ui.DialogSelect({
|
|
487
|
+
title: "Voice settings",
|
|
488
|
+
options: [
|
|
489
|
+
{
|
|
490
|
+
title: "Model",
|
|
491
|
+
value: "model",
|
|
492
|
+
description: `${model.name} · ${downloaded ? "downloaded" : "not downloaded"}`,
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
title: downloaded ? "Re-download active model" : "Download active model",
|
|
496
|
+
value: "download",
|
|
497
|
+
description: `${model.name} · ${formatSize(model)}`,
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
title: "Hold hotkey",
|
|
501
|
+
value: "hotkey",
|
|
502
|
+
description: settings.hotkey ? `hold ${settings.hotkey}` : "disabled",
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
title: "Toggle hotkey",
|
|
506
|
+
value: "toggleHotkey",
|
|
507
|
+
description: settings.toggleHotkey || "disabled",
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
title: "Submit hotkey",
|
|
511
|
+
value: "submitHotkey",
|
|
512
|
+
description: settings.submitHotkey || "disabled",
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
title: "Microphone",
|
|
516
|
+
value: "mic",
|
|
517
|
+
description: settings.mic || "system default",
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
title: "Language",
|
|
521
|
+
value: "language",
|
|
522
|
+
description: settings.language,
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
title: "Auto-submit after /voice",
|
|
526
|
+
value: "autoSubmit",
|
|
527
|
+
description: settings.autoSubmit ? "enabled" : "disabled",
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
title: "Download directory",
|
|
531
|
+
value: "downloadDir",
|
|
532
|
+
description: settings.downloadDir || getCacheDir(ctx.options, settings),
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
title: "Native engine",
|
|
536
|
+
value: "engine",
|
|
537
|
+
description: `${getEngineStatus("whisper.cpp", ctx.options, settings).source} · whisper.cpp`,
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
title: "Diagnostics",
|
|
541
|
+
value: "diagnostics",
|
|
542
|
+
description: "Check recorder, whisper-cli, and model paths.",
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
title: "Show first-run setup again",
|
|
546
|
+
value: "firstRun",
|
|
547
|
+
description: "Open the initial model picker.",
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
onSelect: (option) => {
|
|
551
|
+
if (option.value === "model") showModelPicker(ctx, false);
|
|
552
|
+
if (option.value === "download") downloadActiveModel(ctx);
|
|
553
|
+
if (option.value === "hotkey") {
|
|
554
|
+
showPrompt(ctx, {
|
|
555
|
+
title: "Hold hotkey",
|
|
556
|
+
placeholder: "empty to disable",
|
|
557
|
+
value: settings.hotkey,
|
|
558
|
+
onConfirm: (value) => {
|
|
559
|
+
writeSetting(ctx.api.kv, "hotkey", value.trim());
|
|
560
|
+
ctx.registerCommands();
|
|
561
|
+
showSettings(ctx);
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
if (option.value === "toggleHotkey") {
|
|
566
|
+
showPrompt(ctx, {
|
|
567
|
+
title: "Toggle hotkey",
|
|
568
|
+
placeholder: "ctrl+r",
|
|
569
|
+
value: settings.toggleHotkey,
|
|
570
|
+
onConfirm: (value) => {
|
|
571
|
+
writeSetting(ctx.api.kv, "toggleHotkey", value.trim());
|
|
572
|
+
ctx.registerCommands();
|
|
573
|
+
showSettings(ctx);
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
if (option.value === "submitHotkey") {
|
|
578
|
+
showPrompt(ctx, {
|
|
579
|
+
title: "Submit hotkey",
|
|
580
|
+
placeholder: "leader r or empty to disable",
|
|
581
|
+
value: settings.submitHotkey,
|
|
582
|
+
onConfirm: (value) => {
|
|
583
|
+
writeSetting(ctx.api.kv, "submitHotkey", value.trim());
|
|
584
|
+
ctx.registerCommands();
|
|
585
|
+
showSettings(ctx);
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
if (option.value === "mic") showMicrophonePicker(ctx);
|
|
590
|
+
if (option.value === "language") showLanguagePicker(ctx);
|
|
591
|
+
if (option.value === "autoSubmit") {
|
|
592
|
+
writeSetting(ctx.api.kv, "autoSubmit", !settings.autoSubmit);
|
|
593
|
+
showSettings(ctx);
|
|
594
|
+
}
|
|
595
|
+
if (option.value === "downloadDir") {
|
|
596
|
+
showPrompt(ctx, {
|
|
597
|
+
title: "Download directory",
|
|
598
|
+
placeholder: "~/.cache/opencode-voice",
|
|
599
|
+
value: settings.downloadDir,
|
|
600
|
+
onConfirm: (value) => {
|
|
601
|
+
writeSetting(ctx.api.kv, "downloadDir", value.trim());
|
|
602
|
+
showSettings(ctx);
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
if (option.value === "engine") showEngineManager(ctx);
|
|
607
|
+
if (option.value === "diagnostics") showDiagnostics(ctx);
|
|
608
|
+
if (option.value === "firstRun") showModelPicker(ctx, true);
|
|
609
|
+
},
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function appendTranscription(ctx, text, submit) {
|
|
615
|
+
const next = text.endsWith(" ") ? text : `${text} `;
|
|
616
|
+
await ctx.api.client.tui.appendPrompt({ text: next });
|
|
617
|
+
if (submit) await ctx.api.client.tui.submitPrompt();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function stopAndTranscribe(ctx, submit) {
|
|
621
|
+
if (ctx.runtime.isTranscribing()) {
|
|
622
|
+
toast(ctx.api, "Transcription is already running", "warning");
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
const settings = readSettings(ctx.api.kv);
|
|
628
|
+
const model = getModel(settings.model);
|
|
629
|
+
const audioFile = await ctx.runtime.stop();
|
|
630
|
+
if (!audioFile) return;
|
|
631
|
+
|
|
632
|
+
toast(ctx.api, "Transcribing...");
|
|
633
|
+
const text = await ctx.runtime.transcribe(audioFile, model, settings);
|
|
634
|
+
await appendTranscription(ctx, text, submit || settings.autoSubmit);
|
|
635
|
+
toast(ctx.api, submit || settings.autoSubmit ? "Transcribed and submitted" : "Transcribed", "success");
|
|
636
|
+
} catch (error) {
|
|
637
|
+
toast(ctx.api, error instanceof Error ? error.message : String(error), "error");
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function startVoice(ctx, submit = false, hold = false) {
|
|
642
|
+
if (ctx.runtime.isTranscribing()) {
|
|
643
|
+
toast(ctx.api, "Transcription is already running", "warning");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (ctx.runtime.isRecording()) return;
|
|
648
|
+
|
|
649
|
+
const settings = readSettings(ctx.api.kv);
|
|
650
|
+
const model = getModel(settings.model);
|
|
651
|
+
if (!isModelDownloaded(model, ctx.options, settings)) {
|
|
652
|
+
try {
|
|
653
|
+
await ensureEngineReady(ctx, settings);
|
|
654
|
+
await ensureDownloaded(ctx, model, settings);
|
|
655
|
+
ctx.api.ui.dialog.clear();
|
|
656
|
+
} catch (error) {
|
|
657
|
+
showError(ctx, "Voice setup failed", error);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
await ensureEngineReady(ctx, settings);
|
|
664
|
+
} catch (error) {
|
|
665
|
+
showError(ctx, "Engine install failed", error);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
ctx.runtime.pendingSubmit = submit || settings.autoSubmit;
|
|
671
|
+
await ctx.runtime.start(settings);
|
|
672
|
+
toast(ctx.api, hold ? `Recording. Release ${settings.hotkey || "the hotkey"} to stop.` : submit ? "Recording for submit. Run /voice-submit again to stop." : "Recording. Run /voice again to stop.");
|
|
673
|
+
} catch (error) {
|
|
674
|
+
toast(ctx.api, error instanceof Error ? error.message : String(error), "error");
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function finishVoice(ctx, submit = false) {
|
|
679
|
+
if (!ctx.runtime.isRecording()) return;
|
|
680
|
+
await stopAndTranscribe(ctx, submit || ctx.runtime.pendingSubmit);
|
|
681
|
+
ctx.runtime.pendingSubmit = false;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function toggleVoice(ctx, submit = false) {
|
|
685
|
+
if (ctx.runtime.isTranscribing()) {
|
|
686
|
+
toast(ctx.api, "Transcription is already running", "warning");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (ctx.runtime.isRecording()) {
|
|
691
|
+
await finishVoice(ctx, submit);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
await startVoice(ctx, submit, false);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function stopVoice(ctx) {
|
|
699
|
+
ctx.runtime.cancel();
|
|
700
|
+
toast(ctx.api, "Voice recording cancelled");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function buildBindings(settings) {
|
|
704
|
+
const bindings = [];
|
|
705
|
+
const holdHotkey = settings.hotkey.toLowerCase();
|
|
706
|
+
const toggleHotkey = settings.toggleHotkey.toLowerCase();
|
|
707
|
+
|
|
708
|
+
if (settings.hotkey) {
|
|
709
|
+
bindings.push(
|
|
710
|
+
{ key: settings.hotkey, event: "press", preventDefault: true, cmd: "voice.hold.start", desc: "Hold to record voice" },
|
|
711
|
+
{ key: settings.hotkey, event: "release", preventDefault: true, cmd: "voice.hold.finish", desc: "Release to transcribe voice" },
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (settings.toggleHotkey && toggleHotkey !== holdHotkey) {
|
|
716
|
+
bindings.push({ key: settings.toggleHotkey, event: "press", preventDefault: true, cmd: "voice.record", desc: "Toggle voice recording" });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (settings.submitHotkey) {
|
|
720
|
+
bindings.push({ key: settings.submitHotkey, event: "press", preventDefault: true, cmd: "voice.submit", desc: "Voice input and submit" });
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return bindings;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function buildCommands(ctx) {
|
|
727
|
+
return [
|
|
728
|
+
{
|
|
729
|
+
name: "voice.hold.start",
|
|
730
|
+
title: "Voice: hold start",
|
|
731
|
+
desc: "Start hold-to-talk recording.",
|
|
732
|
+
category: "Voice",
|
|
733
|
+
namespace: "palette",
|
|
734
|
+
hidden: true,
|
|
735
|
+
run: () => startVoice(ctx, false, true),
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
name: "voice.hold.finish",
|
|
739
|
+
title: "Voice: hold finish",
|
|
740
|
+
desc: "Stop hold-to-record and transcribe.",
|
|
741
|
+
category: "Voice",
|
|
742
|
+
namespace: "palette",
|
|
743
|
+
hidden: true,
|
|
744
|
+
run: () => finishVoice(ctx, false),
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
name: "voice.record",
|
|
748
|
+
title: "Voice: record",
|
|
749
|
+
desc: "Toggle local voice recording and append transcription to the prompt.",
|
|
750
|
+
category: "Voice",
|
|
751
|
+
namespace: "palette",
|
|
752
|
+
slashName: "voice",
|
|
753
|
+
slashAliases: ["voice-record"],
|
|
754
|
+
run: () => toggleVoice(ctx, false),
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
name: "voice.submit",
|
|
758
|
+
title: "Voice: submit",
|
|
759
|
+
desc: "Toggle local voice recording and submit after transcription.",
|
|
760
|
+
category: "Voice",
|
|
761
|
+
namespace: "palette",
|
|
762
|
+
slashName: "voice-submit",
|
|
763
|
+
run: () => toggleVoice(ctx, true),
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
name: "voice.stop",
|
|
767
|
+
title: "Voice: stop",
|
|
768
|
+
desc: "Cancel active voice recording or transcription.",
|
|
769
|
+
category: "Voice",
|
|
770
|
+
namespace: "palette",
|
|
771
|
+
slashName: "voice-stop",
|
|
772
|
+
run: () => stopVoice(ctx),
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
name: "voice.settings",
|
|
776
|
+
title: "Voice: settings",
|
|
777
|
+
desc: "Open local voice input settings.",
|
|
778
|
+
category: "Voice",
|
|
779
|
+
namespace: "palette",
|
|
780
|
+
slashName: "voice-settings",
|
|
781
|
+
run: () => showSettings(ctx),
|
|
782
|
+
},
|
|
783
|
+
];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const plugin = {
|
|
787
|
+
id: PLUGIN_ID,
|
|
788
|
+
tui: async (api, options = {}) => {
|
|
789
|
+
const runtime = new VoiceRuntime(options || {});
|
|
790
|
+
const ctx = {
|
|
791
|
+
api,
|
|
792
|
+
options: options || {},
|
|
793
|
+
runtime,
|
|
794
|
+
disposeCommands: undefined,
|
|
795
|
+
registerCommands() {
|
|
796
|
+
if (ctx.disposeCommands) ctx.disposeCommands();
|
|
797
|
+
const settings = readSettings(api.kv);
|
|
798
|
+
ctx.disposeCommands = api.keymap.registerLayer({
|
|
799
|
+
priority: 100,
|
|
800
|
+
commands: buildCommands(ctx),
|
|
801
|
+
bindings: buildBindings(settings),
|
|
802
|
+
});
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
migrateSettings(api.kv);
|
|
807
|
+
ctx.registerCommands();
|
|
808
|
+
api.lifecycle.onDispose(() => {
|
|
809
|
+
if (ctx.disposeCommands) ctx.disposeCommands();
|
|
810
|
+
runtime.cancel();
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
setTimeout(() => {
|
|
814
|
+
if (shouldShowStartupModelPicker(ctx)) showModelPicker(ctx, true);
|
|
815
|
+
}, 250);
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
export default plugin;
|