@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/README.zh.md ADDED
@@ -0,0 +1,174 @@
1
+ <p align="center">
2
+ <a href="https://github.com/ihxnnxs/opencode-voice">
3
+ <picture>
4
+ <source srcset="assets/opencode-voice-dark.svg" media="(prefers-color-scheme: dark)">
5
+ <source srcset="assets/opencode-voice-light.svg" media="(prefers-color-scheme: light)">
6
+ <img src="assets/opencode-voice-light.svg" alt="opencode voice logo">
7
+ </picture>
8
+ </a>
9
+ </p>
10
+ <p align="center">OpenCode TUI 的本地语音转文字插件。</p>
11
+ <p align="center">
12
+ <img alt="status" src="https://img.shields.io/badge/status-mvp-orange?style=flat-square" />
13
+ <img alt="license" src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" />
14
+ <img alt="opencode" src="https://img.shields.io/badge/opencode-%3E%3D1.17.4-black?style=flat-square" />
15
+ <img alt="stt" src="https://img.shields.io/badge/STT-local_whisper.cpp-purple?style=flat-square" />
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="README.md">English</a> |
20
+ <a href="README.ru.md">Русский</a> |
21
+ <a href="README.zh.md">简体中文</a> |
22
+ <a href="README.es.md">Español</a>
23
+ </p>
24
+
25
+ ---
26
+
27
+ ### 安装
28
+
29
+ 通过 OpenCode 一条命令安装:
30
+
31
+ ```bash
32
+ opencode plugin @hxnnxs/opencode-voice
33
+ ```
34
+
35
+ 安装后重启 OpenCode。首次启动会自动下载 managed `whisper.cpp` engine 和你选择的模型。用户不需要手动安装 `whisper-cli`。
36
+
37
+ 可选 CLI 安装器。它会运行相同的 OpenCode plugin install 命令,并预下载 managed engine:
38
+
39
+ ```bash
40
+ npx @hxnnxs/opencode-voice install
41
+ ```
42
+
43
+ 除非你要开发插件,否则不需要 clone 仓库。
44
+
45
+ > [!TIP]
46
+ > 首次启动会打开模型选择器。选择 Whisper 模型,等待下载完成,然后用 `ctrl+r` 向 prompt 听写。
47
+
48
+ ### 要求
49
+
50
+ 插件会管理 STT engine 和模型:
51
+
52
+ - 从 opencode-voice GitHub Release registry 下载 `whisper.cpp`
53
+ - 存到 `~/.cache/opencode-voice/engines/whisper.cpp/<platform>-<arch>/`
54
+ - 首次 setup 时下载你选择的 Whisper 模型
55
+
56
+ 手动安装 `whisper-cli` 是可选项。如果本机已有 binary,`opencode-voice` 仍然可以导入或使用它。
57
+
58
+ 检查本机环境:
59
+
60
+ ```bash
61
+ npx @hxnnxs/opencode-voice doctor
62
+ ```
63
+
64
+ 不打开 OpenCode 也可以安装 managed engine:
65
+
66
+ ```bash
67
+ npx @hxnnxs/opencode-voice engine install whisper.cpp
68
+ ```
69
+
70
+ ### 使用
71
+
72
+ 命令:
73
+
74
+ - `/voice` - 切换录音并插入转写文本
75
+ - `/voice-submit` - 切换录音,插入转写文本并提交
76
+ - `/voice-stop` - 取消当前录音或转写
77
+ - `/voice-settings` - 打开模型、快捷键、麦克风和诊断设置
78
+
79
+ 默认快捷键:
80
+
81
+ ```txt
82
+ ctrl+r -> 开始录音
83
+ ctrl+r -> 停止、转写并插入文本
84
+ ```
85
+
86
+ 默认关闭 hold-to-talk,因为 terminal release events 在不同终端中表现不一致。你仍然可以在 `/voice-settings` 中配置 hold 快捷键。
87
+
88
+ ### 模型
89
+
90
+ 当前通过 `whisper.cpp` 可用:
91
+
92
+ | 模型 | 大小 | 说明 |
93
+ | -------------------- | ------ | ----------------------- |
94
+ | Whisper Small | 465 MB | 默认,多语言 |
95
+ | Whisper Medium Q4_1 | 469 MB | 更高准确率 |
96
+ | Whisper Turbo | 1.5 GB | 大模型,比 full large 快 |
97
+ | Whisper Large Q5_0 | 1.0 GB | 准确,但更慢 |
98
+
99
+ 模型下载支持断点续传、重试、进度显示和 SHA256 校验。
100
+
101
+ 计划中的 sidecar 模型:
102
+
103
+ - Parakeet V3
104
+ - GigaAM v3
105
+ - Moonshine V2 Small
106
+
107
+ ### 平台状态
108
+
109
+ | 平台 | 状态 |
110
+ | ------- | ---- |
111
+ | Linux | 一条命令安装 engine/model;录音使用 `arecord`、`ffmpeg` 或 `sox` |
112
+ | macOS | 一条命令安装 engine/model;native recorder sidecar 发布前使用 `ffmpeg` AVFoundation |
113
+ | Windows | engine 下载路径已准备好;录音还需要 native recorder sidecar |
114
+
115
+ ### 架构
116
+
117
+ 该包采用 OpenCode community TUI 插件使用的公共结构。
118
+
119
+ - npm package 导出 `./tui`
120
+ - 本地开发可以在 `tui.json` 中使用绝对路径
121
+ - 发布后使用 `opencode plugin @hxnnxs/opencode-voice` 安装
122
+ - runtime settings 存储在 OpenCode TUI plugin storage 中
123
+
124
+ 文件:
125
+
126
+ - `index.js` - TUI 插件入口、命令、dialogs、keymap layer
127
+ - `lib/models.js` - 模型 registry、cache paths、default settings
128
+ - `lib/download.js` - 可续传下载和 SHA256 校验
129
+ - `lib/engine.js` - recorder 选择和 `whisper-cli` 转写
130
+ - `lib/engines.js` - managed native engine 下载、状态、导入和移除
131
+ - `bin/opencode-voice.js` - install wrapper 和 diagnostics CLI
132
+
133
+ 语音输入需要 native audio 和 STT binaries。JS 插件负责 OpenCode UI、settings、engine/model downloads 和 prompt insertion。未来的 native sidecar 应替换 shell recorders,并加入 fast VAD 和 Handy-style models。
134
+
135
+ ### Roadmap
136
+
137
+ - npm release 前发布 managed `whisper-cli` release assets
138
+ - 使用 `cpal` 和 VAD 的 Rust recorder sidecar
139
+ - 支持 Parakeet、GigaAM、SenseVoice、Canary 和 Moonshine
140
+ - Windows recorder support
141
+ - 更快的 streaming-style transcription
142
+
143
+ ### 开发
144
+
145
+ 运行检查:
146
+
147
+ ```bash
148
+ npm run check
149
+ npm pack --dry-run
150
+ ```
151
+
152
+ 此 MVP 没有 build step。
153
+
154
+ 从 checkout 安装开发版本:
155
+
156
+ ```bash
157
+ git clone https://github.com/ihxnnxs/opencode-voice.git opencode-voice
158
+ cd opencode-voice
159
+ opencode plugin "$(pwd)"
160
+ ```
161
+
162
+ ### 项目状态
163
+
164
+ 这是独立的 OpenCode plugin。它不是 OpenCode 团队构建的项目,也不隶属于 OpenCode。
165
+
166
+ ### 鸣谢
167
+
168
+ - OpenCode wordmark SVG 改编自公开的 [OpenCode repository](https://github.com/anomalyco/opencode)。`voice` 标记为本插件新增。
169
+ - 本地转写使用 [`whisper.cpp`](https://github.com/ggml-org/whisper.cpp)。
170
+ - 模型下载 metadata 参考了 [Handy](https://github.com/cjpais/Handy) 的 local-first UX。
171
+
172
+ ---
173
+
174
+ **OpenCode** [Website](https://opencode.ai) | [Docs](https://opencode.ai/docs) | [Discord](https://opencode.ai/discord)
package/SECURITY.md ADDED
@@ -0,0 +1,19 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ The current `0.1.x` line receives security fixes while the project is actively maintained.
6
+
7
+ ## Reporting a Vulnerability
8
+
9
+ If this repository is hosted on GitHub, use GitHub private vulnerability reporting when it is available. If private reporting is not available, contact a maintainer privately before sharing exploit details publicly.
10
+
11
+ Please include:
12
+
13
+ - affected version or commit;
14
+ - operating system and Node.js version;
15
+ - steps to reproduce;
16
+ - expected impact;
17
+ - any relevant logs with secrets removed.
18
+
19
+ Avoid posting working exploits, downloaded model paths with private usernames, or environment variables in public issues.
@@ -0,0 +1,27 @@
1
+ <svg width="396" height="42" viewBox="0 0 396 42" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title">
2
+ <title id="title">opencode voice</title>
3
+ <path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
4
+ <path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
5
+ <path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
6
+ <path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
7
+ <path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
8
+ <path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
9
+ <path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
10
+ <path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
11
+ <path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
12
+ <path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
13
+ <path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
14
+ <path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
15
+ <path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
16
+ <path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
17
+ <path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
18
+ <path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
19
+ <path d="M252 6H258V24H264V30H270V36H264V30H258V24H252V6ZM276 6H282V24H276V30H270V36H264V30H270V24H276V6Z" fill="#C4B5FD"/>
20
+ <path d="M306 30H294V18H306V30Z" fill="#6D28D9"/>
21
+ <path d="M306 12H294V30H306V12ZM312 36H288V6H312V36Z" fill="#C4B5FD"/>
22
+ <path d="M324 6H330V12H324V6ZM324 18H330V36H324V18Z" fill="#C4B5FD"/>
23
+ <path d="M366 30H348V18H366V30Z" fill="#6D28D9"/>
24
+ <path d="M366 12H348V30H366V36H342V6H366V12Z" fill="#C4B5FD"/>
25
+ <path d="M396 24V30H378V24H396Z" fill="#6D28D9"/>
26
+ <path d="M378 12V18H390V12H378ZM396 24H378V30H396V36H372V6H396V24Z" fill="#C4B5FD"/>
27
+ </svg>
@@ -0,0 +1,27 @@
1
+ <svg width="396" height="42" viewBox="0 0 396 42" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title">
2
+ <title id="title">opencode voice</title>
3
+ <path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
4
+ <path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
5
+ <path d="M48 30H36V18H48V30Z" fill="#CFCECD"/>
6
+ <path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
7
+ <path d="M84 24V30H66V24H84Z" fill="#CFCECD"/>
8
+ <path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
9
+ <path d="M108 36H96V18H108V36Z" fill="#CFCECD"/>
10
+ <path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
11
+ <path d="M144 30H126V18H144V30Z" fill="#CFCECD"/>
12
+ <path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
13
+ <path d="M168 30H156V18H168V30Z" fill="#CFCECD"/>
14
+ <path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
15
+ <path d="M198 30H186V18H198V30Z" fill="#CFCECD"/>
16
+ <path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
17
+ <path d="M234 24V30H216V24H234Z" fill="#CFCECD"/>
18
+ <path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
19
+ <path d="M252 6H258V24H264V30H270V36H264V30H258V24H252V6ZM276 6H282V24H276V30H270V36H264V30H270V24H276V6Z" fill="#7C3AED"/>
20
+ <path d="M306 30H294V18H306V30Z" fill="#DDD6FE"/>
21
+ <path d="M306 12H294V30H306V12ZM312 36H288V6H312V36Z" fill="#7C3AED"/>
22
+ <path d="M324 6H330V12H324V6ZM324 18H330V36H324V18Z" fill="#7C3AED"/>
23
+ <path d="M366 30H348V18H366V30Z" fill="#DDD6FE"/>
24
+ <path d="M366 12H348V30H366V36H342V6H366V12Z" fill="#7C3AED"/>
25
+ <path d="M396 24V30H378V24H396Z" fill="#DDD6FE"/>
26
+ <path d="M378 12V18H390V12H378ZM396 24H378V30H396V36H372V6H396V24Z" fill="#7C3AED"/>
27
+ </svg>
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ const [command, ...args] = process.argv.slice(2);
8
+
9
+ function hasFlag(flag) {
10
+ return args.includes(flag);
11
+ }
12
+
13
+ function help() {
14
+ console.log(`opencode-voice
15
+
16
+ Usage:
17
+ opencode-voice install [--global] [--no-engine]
18
+ opencode-voice doctor [--json]
19
+ opencode-voice engine status whisper.cpp [--json]
20
+ opencode-voice engine install whisper.cpp
21
+ opencode-voice engine import whisper.cpp [path-to-whisper-cli]
22
+ opencode-voice engine remove whisper.cpp
23
+
24
+ Development install from this checkout:
25
+ opencode plugin <path-to-this-checkout>
26
+ `);
27
+ }
28
+
29
+ function packageName() {
30
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
31
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
32
+ return process.env.OPENCODE_VOICE_PACKAGE || manifest.name || "opencode-voice";
33
+ }
34
+
35
+ async function runtime() {
36
+ const [engine, models, engines] = await Promise.all([import("../lib/engine.js"), import("../lib/models.js"), import("../lib/engines.js")]);
37
+ return { ...engine, ...models, ...engines };
38
+ }
39
+
40
+ async function doctor() {
41
+ const {
42
+ commandExists,
43
+ getAudioDir,
44
+ getCacheDir,
45
+ getEngineStatus,
46
+ getModelsDir,
47
+ listMicrophones,
48
+ probeEngine,
49
+ } = await runtime();
50
+
51
+ const engine = getEngineStatus("whisper.cpp");
52
+ const probe = engine.resolvedBinary ? await probeEngine("whisper.cpp", engine.resolvedBinary) : { ok: false, message: "missing binary" };
53
+ const payload = {
54
+ platform: `${process.platform}-${process.arch}`,
55
+ cacheDir: getCacheDir(),
56
+ modelsDir: getModelsDir(),
57
+ recordingsDir: getAudioDir(),
58
+ engine,
59
+ probe,
60
+ recorders: {
61
+ ffmpeg: commandExists("ffmpeg"),
62
+ arecord: commandExists("arecord"),
63
+ sox: commandExists("sox"),
64
+ },
65
+ microphones: listMicrophones(),
66
+ };
67
+
68
+ if (hasFlag("--json")) {
69
+ console.log(JSON.stringify(payload, null, 2));
70
+ } else {
71
+ console.log(
72
+ [
73
+ "opencode-voice doctor",
74
+ "",
75
+ `Platform: ${payload.platform}`,
76
+ `Cache dir: ${payload.cacheDir}`,
77
+ `Models dir: ${payload.modelsDir}`,
78
+ `Recordings dir: ${payload.recordingsDir}`,
79
+ `Engine source: ${engine.source}`,
80
+ `Managed engine dir: ${engine.managedDir}`,
81
+ `whisper-cli: ${engine.resolvedBinary || "missing"}`,
82
+ `Probe: ${probe.ok ? "ok" : probe.message}`,
83
+ `Recorders: ffmpeg=${payload.recorders.ffmpeg ? "yes" : "no"}, arecord=${payload.recorders.arecord ? "yes" : "no"}, sox=${payload.recorders.sox ? "yes" : "no"}`,
84
+ `Microphones: ${payload.microphones.join(", ")}`,
85
+ ].join("\n"),
86
+ );
87
+ }
88
+
89
+ if (!engine.resolvedBinary || !probe.ok) process.exitCode = 1;
90
+ }
91
+
92
+ async function engineCommand() {
93
+ const [action, engineId = "whisper.cpp", maybePath] = args;
94
+ const { getEngineStatus, importManagedEngine, installManagedEngine, removeManagedEngine } = await runtime();
95
+
96
+ if (engineId !== "whisper.cpp") {
97
+ console.error(`Unsupported engine: ${engineId}`);
98
+ process.exit(1);
99
+ }
100
+
101
+ if (action === "status") {
102
+ const status = getEngineStatus(engineId);
103
+ if (hasFlag("--json")) console.log(JSON.stringify(status, null, 2));
104
+ else {
105
+ console.log(
106
+ [
107
+ `Engine: ${status.id}`,
108
+ `Platform: ${status.platform}`,
109
+ `Source: ${status.source}`,
110
+ `Resolved binary: ${status.resolvedBinary || "missing"}`,
111
+ `Managed binary: ${status.managedBinary}`,
112
+ `Managed installed: ${status.managedInstalled ? "yes" : "no"}`,
113
+ `Managed version: ${status.manifest?.version || "missing"}`,
114
+ ].join("\n"),
115
+ );
116
+ }
117
+ if (!status.resolvedBinary) process.exitCode = 1;
118
+ return;
119
+ }
120
+
121
+ if (action === "import") {
122
+ const status = getEngineStatus(engineId);
123
+ const source = maybePath || (status.source !== "managed" ? status.resolvedBinary : "");
124
+ const result = await importManagedEngine(engineId, source);
125
+ console.log(`Imported ${engineId}: ${result.managedBinary}`);
126
+ return;
127
+ }
128
+
129
+ if (action === "install") {
130
+ const result = await installManagedEngine(engineId, {}, {}, { onProgress: printEngineProgress, onRetry: printEngineRetry });
131
+ console.log(`Installed ${engineId}: ${result.managedBinary}`);
132
+ return;
133
+ }
134
+
135
+ if (action === "remove") {
136
+ const dir = await removeManagedEngine(engineId);
137
+ console.log(`Removed managed ${engineId}: ${dir}`);
138
+ return;
139
+ }
140
+
141
+ help();
142
+ process.exitCode = 1;
143
+ }
144
+
145
+ function printEngineProgress(progress) {
146
+ const label = {
147
+ registry: "registry",
148
+ downloading: "download",
149
+ verifying: "verify archive",
150
+ decompressing: "unpack",
151
+ "verifying-binary": "verify binary",
152
+ probing: "probe",
153
+ done: "done",
154
+ }[progress.state] || progress.state || "engine";
155
+ const percent = Number.isFinite(progress.percent) ? `${Math.round(progress.percent)}%` : "";
156
+ if (progress.state === "downloading" || progress.state === "done") console.log(`engine ${label} ${percent}`.trim());
157
+ }
158
+
159
+ function printEngineRetry({ error, nextAttempt, attempts }) {
160
+ console.warn(`engine retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`);
161
+ }
162
+
163
+ async function installCommand() {
164
+ const pluginArgs = args.filter((arg) => arg !== "--no-engine");
165
+ const result = spawnSync("opencode", ["plugin", packageName(), ...pluginArgs], { stdio: "inherit" });
166
+ if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
167
+
168
+ if (!hasFlag("--no-engine")) {
169
+ const { installManagedEngine } = await runtime();
170
+ console.log("Installing managed voice engine...");
171
+ const engine = await installManagedEngine("whisper.cpp", {}, {}, { onProgress: printEngineProgress, onRetry: printEngineRetry });
172
+ console.log(`Managed voice engine ready: ${engine.managedBinary}`);
173
+ }
174
+ }
175
+
176
+ if (command === "install") await installCommand();
177
+ else if (command === "doctor") await doctor();
178
+ else if (command === "engine") await engineCommand();
179
+ else help();