@bojackduy/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.
@@ -0,0 +1,122 @@
1
+ // Voice LLM picker: reuse OpenCode's own provider/model catalog instead of
2
+ // hand-editing endpoint + model in tui.json.
3
+ //
4
+ // /voice-model lists api.state.provider (the same ~70 providers OpenCode
5
+ // connects) then the chosen provider's models, and stores the selection in
6
+ // kv. llm-client resolves it to endpoint + model + key env at call time.
7
+ // Explicit endpoint/model/apiKeyEnv plugin options always win.
8
+ //
9
+ // Auth caveat: only env-key providers work (key read live from the provider's
10
+ // env list). OAuth/Console-managed credentials are invisible to plugins - for
11
+ // those, keep explicit endpoint options.
12
+
13
+ export function getVoiceProviderSelection(kv) {
14
+ const providerID = kv.get("voice.providerID", null);
15
+ const modelID = kv.get("voice.modelID", null);
16
+ return providerID && modelID ? { providerID, modelID } : null;
17
+ }
18
+
19
+ export function resolveVoiceProviderModel(api, kv) {
20
+ const selection = getVoiceProviderSelection(kv);
21
+ if (!selection) return null;
22
+ const providers = api?.state?.provider || [];
23
+ const provider = providers.find((p) => p?.id === selection.providerID);
24
+ const model = provider?.models?.[selection.modelID];
25
+ if (!provider || !model) return null;
26
+ return { provider, model };
27
+ }
28
+
29
+ export function registerVoiceModel(api, kv, opts, logger) {
30
+ function toast(message, variant = "info") {
31
+ api.ui.toast({ message, variant, duration: 3000 });
32
+ }
33
+
34
+ function pickModel(provider) {
35
+ const models = Object.values(provider.models || {});
36
+ if (models.length === 0) {
37
+ toast(`Provider ${provider.name} has no models`, "warning");
38
+ return;
39
+ }
40
+ const current = kv.get("voice.modelID", null);
41
+ api.ui.dialog.replace(() =>
42
+ api.ui.DialogSelect({
43
+ title: `Voice LLM model (${provider.name})`,
44
+ current,
45
+ options: models.map((m) => ({
46
+ title: m.name || m.id,
47
+ value: m.id,
48
+ description: m.id,
49
+ onSelect() {
50
+ kv.set("voice.providerID", provider.id);
51
+ kv.set("voice.modelID", m.id);
52
+ const envVars = Array.isArray(provider.env) ? provider.env : [];
53
+ const hasKey = envVars.some((e) => e && process.env[e]);
54
+ logger?.log(
55
+ "VOICE",
56
+ `Voice LLM selected provider=${provider.id} model=${m.id}`,
57
+ "debug",
58
+ );
59
+ toast(
60
+ hasKey
61
+ ? `Voice LLM: ${provider.name} / ${m.name || m.id}`
62
+ : `Voice LLM: ${provider.name} / ${m.name || m.id} (no key in ${envVars.join(", ") || "env"} - export one)`,
63
+ hasKey ? "success" : "warning",
64
+ );
65
+ api.ui.dialog.clear();
66
+ },
67
+ })),
68
+ }),
69
+ );
70
+ }
71
+
72
+ const commands = [
73
+ {
74
+ title: "Voice: select LLM",
75
+ value: "voice.model",
76
+ category: "opencode-voice",
77
+ description: "Choose the LLM for voice normalization from OpenCode providers",
78
+ slash: { name: "voice-model" },
79
+ onSelect() {
80
+ const providers = [...(api?.state?.provider || [])].sort((a, b) =>
81
+ (a?.name || a?.id || "").localeCompare(b?.name || b?.id || ""),
82
+ );
83
+ if (providers.length === 0) {
84
+ toast("No OpenCode providers available", "warning");
85
+ return;
86
+ }
87
+ if (opts?.endpoint && opts?.model) {
88
+ toast("Explicit endpoint/model options override the voice selection", "warning");
89
+ }
90
+ const current = kv.get("voice.providerID", null);
91
+ api.ui.dialog.replace(() =>
92
+ api.ui.DialogSelect({
93
+ title: "Voice LLM provider",
94
+ current,
95
+ options: providers.map((p) => ({
96
+ title: p.name || p.id,
97
+ value: p.id,
98
+ description: [p.id, (p.env || []).join(", ")].filter(Boolean).join(" · "),
99
+ onSelect() {
100
+ pickModel(p);
101
+ },
102
+ })),
103
+ }),
104
+ );
105
+ },
106
+ },
107
+ {
108
+ title: "Voice: clear LLM selection",
109
+ value: "voice.model-clear",
110
+ category: "opencode-voice",
111
+ description: "Clear the voice LLM selection (back to endpoint options)",
112
+ slash: { name: "voice-model-clear" },
113
+ onSelect() {
114
+ kv.set("voice.providerID", null);
115
+ kv.set("voice.modelID", null);
116
+ toast("Voice LLM selection cleared");
117
+ },
118
+ },
119
+ ];
120
+
121
+ return { commands };
122
+ }
@@ -0,0 +1,471 @@
1
+ // Persistent local whisper-server for live notes.
2
+ //
3
+ // One-shot STT reuses whisper-cli, which reloads the model on every call -
4
+ // fine for a single utterance, but live notes transcribes a new chunk every
5
+ // few seconds and reloading a multi-GB model that often would fall behind
6
+ // the meeting. whisper-server (shipped alongside whisper-cli by the same
7
+ // whisper.cpp build) loads the model once and serves /inference over HTTP.
8
+ //
9
+ // If the binary is missing, the port is unavailable, or it fails to become
10
+ // ready in time, callers fall back to per-chunk whisper-cli - this is an
11
+ // optimization, never a hard dependency.
12
+ //
13
+ // Ownership rules (shared-port safety):
14
+ // - Before spawning, the port is probed. If anything already answers, the
15
+ // start is refused with an explicit PORT_IN_USE error - the existing
16
+ // process (possibly another tool, possibly an older live-notes session) is
17
+ // never claimed as ours and never killed.
18
+ // - Multi-TUI: callers that OMIT `port` (live-notes, streaming dictation)
19
+ // auto-advance from 127.0.0.1:8090 upward (bounded by `portScanMax`,
20
+ // default 10) and claim the first silent port, so a second TUI lands on
21
+ // 8091+ with its own model load (~2GB RAM each). Callers that pass an
22
+ // EXPLICIT `port` keep the strict refusal above. The bound port is
23
+ // visible via getPort(). When every port in range answers, start fails
24
+ // with PORT_RANGE_EXHAUSTED (recoverable - callers fall back to
25
+ // whisper-cli, audio preserved).
26
+ // - Bind-race safety: two TUIs probing the same free port resolve without
27
+ // orphan kills - if our spawn loses the race (early exit with an
28
+ // address-in-use diagnostic), that port is treated as PORT_IN_USE and the
29
+ // scan advances; only the owned handle is ever signaled.
30
+ // - Readiness is established via GET /health (200 = model loaded, 503 =
31
+ // still loading). Very old server builds without /health fall back to the
32
+ // base URL, but only when the Server response header identifies whisper.cpp
33
+ // - any HTTP response (a proxy, another app) must not count as ready.
34
+ // - stop() only ever signals the process this client spawned (by pid handle),
35
+ // never a port-wide pkill.
36
+
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+ import { spawn } from "node:child_process";
40
+
41
+ const DEFAULT_HOST = "127.0.0.1";
42
+ const DEFAULT_PORT = 8090;
43
+ // Default-port scan bound: try 8090..8090+MAX-1 before giving up, so ~10
44
+ // TUIs can coexist on one machine (one model load per TUI, ~2GB RAM each).
45
+ const DEFAULT_PORT_SCAN_MAX = 10;
46
+ const READY_TIMEOUT_MS = 20000;
47
+ const READY_POLL_MS = 300;
48
+
49
+ export function whisperServerOnPath() {
50
+ const pathDirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
51
+ return pathDirs.some((dir) => fs.existsSync(path.join(dir, "whisper-server")));
52
+ }
53
+
54
+ export function buildWhisperServerArgs({ modelPath, language, host, port, threads }) {
55
+ const args = [
56
+ "-m",
57
+ modelPath,
58
+ "-l",
59
+ language || "auto",
60
+ "--host",
61
+ host || DEFAULT_HOST,
62
+ "--port",
63
+ String(port || DEFAULT_PORT),
64
+ ];
65
+ if (threads) args.push("-t", String(threads));
66
+ return args;
67
+ }
68
+
69
+ function buildInferenceRequest(audioBuffer, responseFormat = "json") {
70
+ const blob = new Blob([audioBuffer], { type: "audio/wav" });
71
+ const form = new FormData();
72
+ form.append("file", blob, "audio.wav");
73
+ form.append("response_format", responseFormat);
74
+ form.append("temperature", "0.0");
75
+ return form;
76
+ }
77
+
78
+ // A server that exits before ready because another process won the bind
79
+ // race prints an address-in-use diagnostic. Only that pattern advances the
80
+ // port scan - any other early exit (missing model, bad flags) aborts, since
81
+ // another port would fail the same way.
82
+ function isAddressInUse(stderr) {
83
+ return /address already in use|EADDRINUSE|already in use|failed to bind|bind.*fail|address_in_use/i.test(
84
+ stderr || "",
85
+ );
86
+ }
87
+
88
+ export { DEFAULT_HOST, DEFAULT_PORT, DEFAULT_PORT_SCAN_MAX };
89
+
90
+ export function createWhisperServerClient(options = {}) {
91
+ const {
92
+ modelPath,
93
+ language,
94
+ host = DEFAULT_HOST,
95
+ threads,
96
+ logger,
97
+ readyTimeoutMs = READY_TIMEOUT_MS,
98
+ readyPollMs = READY_POLL_MS,
99
+ portScanMax = DEFAULT_PORT_SCAN_MAX,
100
+ deps = {},
101
+ } = options;
102
+ // Explicit port (caller passed `port`) keeps strict behavior: refuse with
103
+ // PORT_IN_USE, never claim/kill. Default path (no `port` given) scans
104
+ // upward from 8090 and claims the first silent port.
105
+ const explicitPort = options.port !== undefined && options.port !== null;
106
+ const requestedPort = options.port ?? DEFAULT_PORT;
107
+ const spawnFn = deps.spawn ?? spawn;
108
+ const fetchFn = deps.fetch ?? fetch;
109
+ let proc = null;
110
+ let ready = false;
111
+ let ownerPid = null;
112
+ let lastError = null;
113
+ let boundPort = requestedPort;
114
+ const baseUrlFor = (p) => `http://${host}:${p}`;
115
+
116
+ // True when something already answers on the port - i.e. a foreign owner.
117
+ // Called before spawn only, so any responder is by definition not ours.
118
+ async function portResponds(p) {
119
+ const base = baseUrlFor(p);
120
+ for (const url of [`${base}/health`, base]) {
121
+ try {
122
+ const resp = await fetchFn(url, { signal: AbortSignal.timeout(1500) });
123
+ if (resp) return true;
124
+ } catch {
125
+ // Connection refused / timeout: keep checking the next URL.
126
+ }
127
+ }
128
+ return false;
129
+ }
130
+
131
+ // One readiness probe. Returns "ready" | "loading" | "down".
132
+ async function probeOnce() {
133
+ const base = baseUrlFor(boundPort);
134
+ try {
135
+ const resp = await fetchFn(`${base}/health`, { signal: AbortSignal.timeout(1500) });
136
+ if (resp?.ok) return "ready";
137
+ if (resp?.status === 503) return "loading";
138
+ if (resp?.status === 404) {
139
+ // Build predates /health: only accept the base URL when the Server
140
+ // header identifies whisper.cpp, so a proxy or another app on the
141
+ // port is never mistaken for our service.
142
+ try {
143
+ const baseResp = await fetchFn(base, { signal: AbortSignal.timeout(1500) });
144
+ const server = baseResp?.headers?.get?.("server") || "";
145
+ if (server.toLowerCase().includes("whisper")) return "ready";
146
+ } catch {
147
+ // Fall through to "down".
148
+ }
149
+ return "down";
150
+ }
151
+ return "down";
152
+ } catch {
153
+ return "down";
154
+ }
155
+ }
156
+
157
+ async function pollReady(deadline) {
158
+ while (Date.now() < deadline) {
159
+ if (!proc) return false; // exited before becoming ready
160
+ const state = await probeOnce();
161
+ if (state === "ready") {
162
+ if (!proc) return false; // raced with exit; not ours to claim
163
+ return true;
164
+ }
165
+ await new Promise((r) => setTimeout(r, readyPollMs));
166
+ }
167
+ return false;
168
+ }
169
+
170
+ // Attempt one port: probe, spawn, wait for readiness. Returns
171
+ // { ok:true } or { ok:false, code, message, advancable } where advancable
172
+ // means "another port may succeed" (PORT_IN_USE only). stop() inside only
173
+ // ever signals the owned handle spawned here - never a foreign pid.
174
+ async function startOnPort(port) {
175
+ boundPort = port;
176
+ if (await portResponds(port)) {
177
+ return {
178
+ ok: false,
179
+ code: "PORT_IN_USE",
180
+ advancable: true,
181
+ message: `Port ${port} already answers - owned by another process, not starting a second whisper-server`,
182
+ };
183
+ }
184
+ const args = buildWhisperServerArgs({ modelPath, language, host, port, threads });
185
+ logger?.log("VOICE", `Starting whisper-server ${args.join(" ")}`, "debug");
186
+ try {
187
+ proc = spawnFn("whisper-server", args, { stdio: ["ignore", "ignore", "pipe"] });
188
+ } catch (err) {
189
+ proc = null;
190
+ return {
191
+ ok: false,
192
+ code: "SPAWN_FAILED",
193
+ advancable: false,
194
+ message: `Failed to spawn whisper-server: ${err.message}`,
195
+ };
196
+ }
197
+
198
+ let stderr = "";
199
+ // Bind to the spawned handle: a late event from an old child after a
200
+ // restart must never wipe the new handle (ready false, stop() unable to
201
+ // signal). Shared state mutates only when proc is still this handle.
202
+ const owned = proc;
203
+ proc.stderr?.on("data", (chunk) => {
204
+ stderr += chunk.toString();
205
+ });
206
+ proc.on("exit", (code) => {
207
+ if (proc !== owned) return;
208
+ if (!ready) {
209
+ logger?.log(
210
+ "VOICE",
211
+ `whisper-server exited before ready code=${code} stderr=${stderr.trim().slice(-300)}`,
212
+ "warn",
213
+ );
214
+ }
215
+ proc = null;
216
+ ready = false;
217
+ ownerPid = null;
218
+ });
219
+ proc.on("error", (err) => {
220
+ if (proc !== owned) return;
221
+ logger?.log("VOICE", `whisper-server process error: ${err.message}`, "warn");
222
+ proc = null;
223
+ ready = false;
224
+ ownerPid = null;
225
+ });
226
+
227
+ ready = await pollReady(Date.now() + readyTimeoutMs);
228
+ if (!ready) {
229
+ const exitedEarly = !proc;
230
+ const tail = stderr.trim().slice(-300);
231
+ if (exitedEarly && isAddressInUse(tail)) {
232
+ // Bind race: a sibling TUI claimed the port between our probe and
233
+ // our spawn. Our proc already exited; nothing foreign to signal.
234
+ stop();
235
+ return {
236
+ ok: false,
237
+ code: "PORT_IN_USE",
238
+ advancable: true,
239
+ message: `Port ${port} lost the bind race - owned by another process, trying the next port`,
240
+ };
241
+ }
242
+ const failure = exitedEarly
243
+ ? {
244
+ code: "EXITED_EARLY",
245
+ message: `whisper-server exited before ready stderr=${tail}`,
246
+ }
247
+ : {
248
+ code: "START_TIMEOUT",
249
+ message: `whisper-server did not become ready in time (${readyTimeoutMs}ms)`,
250
+ };
251
+ logger?.log("VOICE", failure.message, "warn");
252
+ stop();
253
+ return { ok: false, ...failure, advancable: false };
254
+ }
255
+ ownerPid = proc?.pid ?? null;
256
+ return { ok: true };
257
+ }
258
+
259
+ async function start() {
260
+ if (proc && ready) return true; // reuse the owned, already-ready server
261
+ if (!whisperServerOnPath()) {
262
+ lastError = {
263
+ code: "SERVER_BINARY_MISSING",
264
+ message: "whisper-server not on PATH, falling back to whisper-cli",
265
+ };
266
+ logger?.log("VOICE", lastError.message, "warn");
267
+ return false;
268
+ }
269
+ if (explicitPort) {
270
+ const r = await startOnPort(requestedPort);
271
+ if (!r.ok) {
272
+ lastError = { code: r.code, message: r.message };
273
+ logger?.log("VOICE", lastError.message, "warn");
274
+ return false;
275
+ }
276
+ lastError = null;
277
+ logger?.log(
278
+ "VOICE",
279
+ `whisper-server ready at ${baseUrlFor(boundPort)} pid=${ownerPid}`,
280
+ "debug",
281
+ );
282
+ return true;
283
+ }
284
+ const scanMax = Math.max(1, Math.floor(portScanMax) || 1);
285
+ for (let i = 0; i < scanMax; i += 1) {
286
+ const port = DEFAULT_PORT + i;
287
+ const r = await startOnPort(port);
288
+ if (r.ok) {
289
+ lastError = null;
290
+ logger?.log(
291
+ "VOICE",
292
+ `whisper-server ready at ${baseUrlFor(boundPort)} pid=${ownerPid}`,
293
+ "debug",
294
+ );
295
+ return true;
296
+ }
297
+ if (!r.advancable) {
298
+ lastError = { code: r.code, message: r.message };
299
+ logger?.log("VOICE", lastError.message, "warn");
300
+ return false;
301
+ }
302
+ logger?.log("VOICE", r.message, "warn");
303
+ }
304
+ lastError = {
305
+ code: "PORT_RANGE_EXHAUSTED",
306
+ message: `No free port in ${DEFAULT_PORT}..${DEFAULT_PORT + scanMax - 1} - all answer, falling back to whisper-cli`,
307
+ };
308
+ logger?.log("VOICE", lastError.message, "warn");
309
+ return false;
310
+ }
311
+
312
+ // Transcribe one WAV file. responseFormat "verbose_json" additionally
313
+ // returns snapshot-relative segments (start/end/duration are relative to
314
+ // the submitted audio, NOT absolute session time - see
315
+ // lib/streaming-transcript.js). Defaults to "json" (live-notes behavior).
316
+ // options.signal aborts the HTTP request (streaming stop/cancel/restart
317
+ // must not leave a doomed request running while a new session starts).
318
+ async function transcribeFile(wavPath, options = {}) {
319
+ if (!ready) {
320
+ return {
321
+ error: lastError?.message || "whisper-server not ready",
322
+ code: lastError?.code || "NOT_READY",
323
+ };
324
+ }
325
+ const responseFormat = options.responseFormat || "json";
326
+ const timeout = AbortSignal.timeout(60000);
327
+ const signal = options.signal ? AbortSignal.any([timeout, options.signal]) : timeout;
328
+ try {
329
+ const audioBuffer = await fs.promises.readFile(wavPath);
330
+ const resp = await fetchFn(`${baseUrlFor(boundPort)}/inference`, {
331
+ method: "POST",
332
+ body: buildInferenceRequest(audioBuffer, responseFormat),
333
+ signal,
334
+ });
335
+ if (!resp.ok) {
336
+ return { error: `whisper-server responded ${resp.status}`, code: "BAD_STATUS" };
337
+ }
338
+ const data = await resp.json();
339
+ const result = { text: (data?.text || "").trim() };
340
+ if (Array.isArray(data?.segments)) result.segments = data.segments;
341
+ return result;
342
+ } catch (err) {
343
+ if (err?.name === "AbortError") {
344
+ return { error: "whisper-server request aborted", code: "ABORTED" };
345
+ }
346
+ return { error: `whisper-server request failed: ${err.message}`, code: "REQUEST_FAILED" };
347
+ }
348
+ }
349
+
350
+ // Transcribe an in-memory WAV buffer (streaming snapshots never touch the
351
+ // disk). Same contract as transcribeFile (including options.signal).
352
+ async function transcribeBuffer(wavBuffer, options = {}) {
353
+ if (!ready) {
354
+ return {
355
+ error: lastError?.message || "whisper-server not ready",
356
+ code: lastError?.code || "NOT_READY",
357
+ };
358
+ }
359
+ const responseFormat = options.responseFormat || "json";
360
+ const timeout = AbortSignal.timeout(60000);
361
+ const signal = options.signal ? AbortSignal.any([timeout, options.signal]) : timeout;
362
+ try {
363
+ const resp = await fetchFn(`${baseUrlFor(boundPort)}/inference`, {
364
+ method: "POST",
365
+ body: buildInferenceRequest(wavBuffer, responseFormat),
366
+ signal,
367
+ });
368
+ if (!resp.ok) {
369
+ return { error: `whisper-server responded ${resp.status}`, code: "BAD_STATUS" };
370
+ }
371
+ const data = await resp.json();
372
+ const result = { text: (data?.text || "").trim() };
373
+ if (Array.isArray(data?.segments)) result.segments = data.segments;
374
+ return result;
375
+ } catch (err) {
376
+ if (err?.name === "AbortError") {
377
+ return { error: "whisper-server request aborted", code: "ABORTED" };
378
+ }
379
+ return { error: `whisper-server request failed: ${err.message}`, code: "REQUEST_FAILED" };
380
+ }
381
+ }
382
+
383
+ // Stop ONLY the process this client spawned (by handle). A foreign process
384
+ // on the same port is never signaled - see PORT_IN_USE above.
385
+
386
+ // Stop ONLY the process this client spawned (by handle). A foreign process
387
+ // on the same port is never signaled - see PORT_IN_USE above.
388
+ function stop() {
389
+ ready = false;
390
+ ownerPid = null;
391
+ if (proc) {
392
+ const owned = proc;
393
+ proc = null;
394
+ try {
395
+ owned.kill("SIGTERM");
396
+ } catch {}
397
+ }
398
+ }
399
+
400
+ function isRunning() {
401
+ return ready && proc !== null;
402
+ }
403
+
404
+ function getLastError() {
405
+ return lastError;
406
+ }
407
+
408
+ function getOwnerPid() {
409
+ return ownerPid;
410
+ }
411
+
412
+ // The port this client is bound (or will try to bind) to. Default-path
413
+ // clients advance upward from 8090; explicit-port clients stay put.
414
+ function getPort() {
415
+ return boundPort;
416
+ }
417
+
418
+ return {
419
+ start,
420
+ transcribeFile,
421
+ transcribeBuffer,
422
+ stop,
423
+ isRunning,
424
+ getLastError,
425
+ getOwnerPid,
426
+ getPort,
427
+ };
428
+ }
429
+
430
+ // Shared rendezvous so the streaming dictation controller and live-notes
431
+ // reuse one owned server per model/language/port instead of racing for the
432
+ // port. Callers that omit `port` share one auto-scan client (keyed "auto")
433
+ // whose start() claims the first silent port from 8090 upward; callers with
434
+ // an explicit port share per that port and keep strict PORT_IN_USE refusal.
435
+ // A second claimant never kills: explicit collisions fail, auto scans ahead.
436
+ const sharedServers = new Map();
437
+
438
+ function sharedKey({ modelPath, language, host, port }) {
439
+ // The actual bound port distinguishes entries: explicit ports key per
440
+ // port, the default path keys per "auto" so in-process sharing coalesces
441
+ // to the single auto-scan client (whose getPort() reports the claim).
442
+ const portPart = port === undefined || port === null ? "auto" : String(port);
443
+ return `${host || DEFAULT_HOST}:${portPart}:${modelPath}:${language || "auto"}`;
444
+ }
445
+
446
+ export function acquireSharedWhisperServer(options = {}) {
447
+ const key = sharedKey(options);
448
+ let entry = sharedServers.get(key);
449
+ if (!entry) {
450
+ entry = { client: createWhisperServerClient(options), refs: 0 };
451
+ sharedServers.set(key, entry);
452
+ }
453
+ entry.refs += 1;
454
+ let released = false;
455
+ return {
456
+ client: entry.client,
457
+ release() {
458
+ if (released) return;
459
+ released = true;
460
+ entry.refs -= 1;
461
+ if (entry.refs <= 0) {
462
+ sharedServers.delete(key);
463
+ entry.client.stop();
464
+ }
465
+ },
466
+ };
467
+ }
468
+
469
+ export function __clearSharedWhisperServersForTest() {
470
+ sharedServers.clear();
471
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@bojackduy/opencode-voice",
3
+ "version": "0.1.0",
4
+ "description": "Speech-to-text and text-to-speech for OpenCode. Record voice prompts with whisper-cpp, hear responses via Piper TTS, with LLM normalization through any OpenAI-compatible endpoint.",
5
+ "keywords": [
6
+ "opencode",
7
+ "piper",
8
+ "plugin",
9
+ "speech-to-text",
10
+ "stt",
11
+ "text-to-speech",
12
+ "tts",
13
+ "voice",
14
+ "whisper"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/bojackduy/opencode-voice.git"
20
+ },
21
+ "files": [
22
+ "index.js",
23
+ "lib"
24
+ ],
25
+ "type": "module",
26
+ "main": "index.js",
27
+ "exports": {
28
+ ".": {
29
+ "import": "./index.js"
30
+ },
31
+ "./tui": {
32
+ "import": "./index.js"
33
+ }
34
+ },
35
+ "scripts": {
36
+ "lint": "npx oxlint .",
37
+ "fmt": "npx oxfmt --check .",
38
+ "fmt:fix": "npx oxfmt --write .",
39
+ "test": "node --test",
40
+ "check": "npm run lint && npm run fmt",
41
+ "prepack": "npm run check"
42
+ },
43
+ "devDependencies": {
44
+ "oxfmt": "0.50.0",
45
+ "oxlint": "1.65.0"
46
+ }
47
+ }