opencode-voice-plugin 0.1.0 → 0.1.1
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.md +213 -201
- package/README.pt-BR.md +389 -0
- package/package.json +2 -1
- package/src/capture.ts +4 -4
- package/src/ffmpeg.ts +1 -1
- package/src/settings.ts +1 -1
- package/src/transcribe.ts +3 -3
- package/src/tui.tsx +26 -26
package/README.md
CHANGED
|
@@ -1,61 +1,63 @@
|
|
|
1
1
|
# opencode-voice-plugin
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
(ou use `ctrl+y`), fale, e o texto transcrito vira uma mensagem da sessão.
|
|
3
|
+
**English** · [Português (BR)](README.pt-BR.md)
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
segredo. Funciona em **Windows, Linux e macOS** a partir do mesmo código, e é instalado **na máquina**
|
|
9
|
-
(não por projeto).
|
|
5
|
+
Voice dictation for **OpenCode V2**: a microphone icon (`○`) in the prompt footer. Click it (or press
|
|
6
|
+
`ctrl+y`), speak, and the transcribed text becomes a message in the session.
|
|
10
7
|
|
|
11
|
-
|
|
8
|
+
Transcription uses **OpenRouter** (`POST /api/v1/audio/transcriptions`), and the API key is reused
|
|
9
|
+
from OpenCode itself when you already have OpenRouter connected — no environment variable, no
|
|
10
|
+
duplicating secrets. It works on **Windows, Linux and macOS** from the same code, and installs
|
|
11
|
+
**machine-wide** (not per project).
|
|
12
|
+
|
|
13
|
+
[Repository](https://github.com/kabrau/opencode-voice-plugin) ·
|
|
12
14
|
[Issues](https://github.com/kabrau/opencode-voice-plugin/issues) ·
|
|
13
15
|
[Design](DESIGN.md) · MIT
|
|
14
16
|
|
|
15
17
|
---
|
|
16
18
|
|
|
17
|
-
##
|
|
19
|
+
## How it works
|
|
18
20
|
|
|
19
|
-
1.
|
|
20
|
-
2.
|
|
21
|
-
3.
|
|
22
|
-
4.
|
|
21
|
+
1. `○` in the prompt footer (visible inside a session).
|
|
22
|
+
2. Click it, press `ctrl+y`, or type `/voice` → recording starts (`●` + seconds).
|
|
23
|
+
3. Click again (or wait for `maxSeconds`) → it stops and transcribes (`…`).
|
|
24
|
+
4. The **Dictation** dialog opens with the text — edit if you want and confirm → the message is sent.
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
`"allowFromHome": false`
|
|
26
|
+
It also works on the **home screen**: since there is no session yet, `ctrl+y` **creates one right
|
|
27
|
+
away** (before recording) and sends the text there — the same as typing and pressing Enter. Turn it
|
|
28
|
+
off with `"allowFromHome": false` if you prefer the microphone to work only inside a session.
|
|
27
29
|
|
|
28
30
|
---
|
|
29
31
|
|
|
30
|
-
##
|
|
32
|
+
## Requirements
|
|
31
33
|
|
|
32
34
|
- OpenCode **V2** (`opencode --version`).
|
|
33
|
-
- **ffmpeg**
|
|
35
|
+
- **ffmpeg** on the system:
|
|
34
36
|
- Windows: `choco install ffmpeg`
|
|
35
37
|
- Debian/Ubuntu: `sudo apt install ffmpeg pulseaudio-utils`
|
|
36
38
|
- macOS: `brew install ffmpeg`
|
|
37
|
-
-
|
|
39
|
+
- An **OpenRouter** API key (see [Cost](#cost) and [API key](#api-key)).
|
|
38
40
|
|
|
39
41
|
---
|
|
40
42
|
|
|
41
|
-
##
|
|
43
|
+
## Install (via npm — recommended)
|
|
42
44
|
|
|
43
45
|
```bash
|
|
44
46
|
opencode plugin add opencode-voice-plugin
|
|
45
47
|
```
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
That's it — no cloning, no copying files. To update or remove:
|
|
48
50
|
|
|
49
51
|
```bash
|
|
50
52
|
opencode plugin update opencode-voice-plugin
|
|
51
53
|
opencode plugin remove opencode-voice-plugin
|
|
52
54
|
```
|
|
53
55
|
|
|
54
|
-
>
|
|
55
|
-
> [
|
|
56
|
-
>
|
|
56
|
+
> To **configure options** (icons, keybind, device, etc.), see
|
|
57
|
+
> [Where to declare options](#where-to-declare-options) — that is a detail `plugin add` alone
|
|
58
|
+
> does not cover.
|
|
57
59
|
|
|
58
|
-
###
|
|
60
|
+
### Without npm (straight from GitHub)
|
|
59
61
|
|
|
60
62
|
```bash
|
|
61
63
|
opencode plugin add github:kabrau/opencode-voice-plugin
|
|
@@ -63,50 +65,50 @@ opencode plugin add github:kabrau/opencode-voice-plugin
|
|
|
63
65
|
|
|
64
66
|
---
|
|
65
67
|
|
|
66
|
-
##
|
|
68
|
+
## Manual install (machine-wide)
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
If you prefer a local copy, OpenCode discovers plugins in the global config directory:
|
|
69
71
|
|
|
70
72
|
```
|
|
71
|
-
<config
|
|
72
|
-
<config
|
|
73
|
+
<global-config>/plugins/<name>/index.ts # server entrypoint
|
|
74
|
+
<global-config>/plugins/<name>/tui.tsx # TUI entrypoint (the plugin itself)
|
|
73
75
|
```
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
`~/.config/opencode` (
|
|
77
|
+
On Windows the global config lives in `C:\Users\<you>\.config\opencode`. On Linux/macOS it is
|
|
78
|
+
`~/.config/opencode` (or `$XDG_CONFIG_HOME/opencode`).
|
|
77
79
|
|
|
78
80
|
### Windows (PowerShell)
|
|
79
81
|
|
|
80
82
|
```powershell
|
|
81
83
|
git clone https://github.com/kabrau/opencode-voice-plugin.git
|
|
82
|
-
$
|
|
83
|
-
New-Item -ItemType Directory -Force -Path $
|
|
84
|
-
Copy-Item .\opencode-voice-plugin\src\* $
|
|
84
|
+
$dest = "$env:USERPROFILE\.config\opencode\plugins\voice"
|
|
85
|
+
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
|
86
|
+
Copy-Item .\opencode-voice-plugin\src\* $dest
|
|
85
87
|
```
|
|
86
88
|
|
|
87
89
|
### Linux / macOS
|
|
88
90
|
|
|
89
91
|
```bash
|
|
90
92
|
git clone https://github.com/kabrau/opencode-voice-plugin.git
|
|
91
|
-
|
|
92
|
-
mkdir -p "$
|
|
93
|
-
cp opencode-voice-plugin/src/* "$
|
|
93
|
+
dest="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/voice"
|
|
94
|
+
mkdir -p "$dest"
|
|
95
|
+
cp opencode-voice-plugin/src/* "$dest/"
|
|
94
96
|
```
|
|
95
97
|
|
|
96
|
-
|
|
98
|
+
Then **restart `opencode`**.
|
|
97
99
|
|
|
98
|
-
###
|
|
100
|
+
### Alternative install (pointing at the clone)
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
Point the global `cli.json` at the cloned repository. This keeps the plugin updated via `git pull`
|
|
103
|
+
and is the way to pass options, since the discovery folder has nowhere to declare them:
|
|
102
104
|
|
|
103
105
|
```json
|
|
104
106
|
{
|
|
105
107
|
"plugins": [
|
|
106
108
|
{
|
|
107
|
-
"package": "file:///
|
|
109
|
+
"package": "file:///path/to/opencode-voice-plugin",
|
|
108
110
|
"options": {
|
|
109
|
-
"language": "
|
|
111
|
+
"language": "en",
|
|
110
112
|
"keybind": "ctrl+y"
|
|
111
113
|
}
|
|
112
114
|
}
|
|
@@ -114,58 +116,58 @@ e é o jeito de passar options, já que a pasta de descoberta não tem onde decl
|
|
|
114
116
|
}
|
|
115
117
|
```
|
|
116
118
|
|
|
117
|
-
>
|
|
118
|
-
>
|
|
119
|
+
> The path can be an absolute path or a `file://` URL. **Add** it to the existing `plugins` array in
|
|
120
|
+
> `~/.config/opencode/cli.json`, without deleting the rest of the file.
|
|
119
121
|
|
|
120
122
|
---
|
|
121
123
|
|
|
122
|
-
##
|
|
124
|
+
## API key
|
|
123
125
|
|
|
124
|
-
|
|
126
|
+
The plugin resolves the key in this order:
|
|
125
127
|
|
|
126
|
-
1. **
|
|
127
|
-
2. **
|
|
128
|
-
|
|
128
|
+
1. **Environment variable** — `OPENROUTER_API_KEY` (or whatever `apiKeyEnv` says), if present.
|
|
129
|
+
2. **OpenCode's own credential store** — if you already connected OpenRouter, the plugin uses that
|
|
130
|
+
key automatically. **Nothing to configure.**
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
In other words: if OpenRouter already shows up as connected, you **do not** need to create any
|
|
133
|
+
variable. Check with:
|
|
132
134
|
|
|
133
135
|
```bash
|
|
134
|
-
opencode auth list #
|
|
136
|
+
opencode auth list # should list "OpenRouter ... stored"
|
|
135
137
|
```
|
|
136
138
|
|
|
137
|
-
|
|
139
|
+
If you prefer the env var (or do not have OpenRouter connected):
|
|
138
140
|
|
|
139
141
|
```powershell
|
|
140
|
-
setx OPENROUTER_API_KEY "sk-or-..." # Windows;
|
|
142
|
+
setx OPENROUTER_API_KEY "sk-or-..." # Windows; reopen the terminal afterwards
|
|
141
143
|
```
|
|
142
144
|
|
|
143
145
|
```bash
|
|
144
146
|
export OPENROUTER_API_KEY="sk-or-..." # Linux/macOS (~/.bashrc / ~/.zshrc)
|
|
145
147
|
```
|
|
146
148
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
Create a key at <https://openrouter.ai/settings/keys>. Either way the key is **never** logged,
|
|
150
|
+
copied or stored by the plugin — and reading the store is read-only, from the file OpenCode itself
|
|
151
|
+
maintains (`<data>/opencode/auth.json`).
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
For an env-var-only policy, use `"apiKeyFromAuth": false`; to read from another integration (for
|
|
154
|
+
example the Console one), change `authIntegration`.
|
|
153
155
|
|
|
154
156
|
---
|
|
155
157
|
|
|
156
|
-
##
|
|
158
|
+
## Configuration
|
|
157
159
|
|
|
158
|
-
###
|
|
160
|
+
### Where to declare options
|
|
159
161
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
Options are read from **`cli.json`** (global config), **not** from `opencode.jsonc`. This holds even
|
|
163
|
+
if you installed by copying into `plugins/voice/`: add the entry below to the `plugins` array in
|
|
164
|
+
`~/.config/opencode/cli.json`, keeping the rest of the file intact.
|
|
163
165
|
|
|
164
166
|
```json
|
|
165
167
|
{
|
|
166
168
|
"plugins": [
|
|
167
169
|
{
|
|
168
|
-
"package": "C:\\Users\\<
|
|
170
|
+
"package": "C:\\Users\\<you>\\.config\\opencode\\plugins\\voice",
|
|
169
171
|
"options": {
|
|
170
172
|
"autoSubmit": true
|
|
171
173
|
}
|
|
@@ -174,214 +176,224 @@ mesmo que você tenha instalado por cópia em `plugins/voice/`: adicione a entra
|
|
|
174
176
|
}
|
|
175
177
|
```
|
|
176
178
|
|
|
177
|
-
>
|
|
178
|
-
>
|
|
179
|
-
>
|
|
179
|
+
> Verified in both directions: with this entry `context.options` receives the object; declaring the
|
|
180
|
+
> same options in the `plugins` array of **`opencode.jsonc`** makes the TUI component receive `{}`.
|
|
181
|
+
> There is no duplication from the plugin also being discovered in the global folder.
|
|
180
182
|
|
|
181
|
-
|
|
183
|
+
Every option is optional.
|
|
182
184
|
|
|
183
|
-
| Option | Default |
|
|
185
|
+
| Option | Default | Description |
|
|
184
186
|
|---|---|---|
|
|
185
|
-
| `apiKeyEnv` | `"OPENROUTER_API_KEY"` |
|
|
186
|
-
| `apiKeyFromAuth` | `true` |
|
|
187
|
-
| `authIntegration` | `"openrouter"` |
|
|
188
|
-
| `model` | `"openai/whisper-large-v3"` |
|
|
189
|
-
| `language` | `"
|
|
190
|
-
| `baseUrl` | `"https://openrouter.ai/api/v1"` | Base
|
|
191
|
-
| `device` | `null` |
|
|
192
|
-
| `ffmpegPath` | `null` |
|
|
193
|
-
| `keybind` | `"ctrl+y"` |
|
|
194
|
-
| `autoSubmit` | `false` | `true`
|
|
195
|
-
| `allowFromHome` | `true` |
|
|
196
|
-
| `maxSeconds` | `55` |
|
|
197
|
-
| `keepAudio` | `false` | `true`
|
|
198
|
-
| `icons` | `{ idle: "○", recording: "●", transcribing: "…" }` |
|
|
199
|
-
|
|
200
|
-
> **
|
|
201
|
-
>
|
|
202
|
-
>
|
|
203
|
-
>
|
|
204
|
-
>
|
|
187
|
+
| `apiKeyEnv` | `"OPENROUTER_API_KEY"` | Name of the environment variable holding the key. Takes priority over the store. |
|
|
188
|
+
| `apiKeyFromAuth` | `true` | When the env var is absent, use the key already stored by OpenCode. |
|
|
189
|
+
| `authIntegration` | `"openrouter"` | OpenCode integration to read the key from. |
|
|
190
|
+
| `model` | `"openai/whisper-large-v3"` | OpenRouter STT model. |
|
|
191
|
+
| `language` | `"auto"` | ISO-639-1 code, or `"auto"` to auto-detect. |
|
|
192
|
+
| `baseUrl` | `"https://openrouter.ai/api/v1"` | Base URL of a provider compatible with `/audio/transcriptions`. |
|
|
193
|
+
| `device` | `null` | Microphone. `null` = auto-detect. Per-OS formats below. |
|
|
194
|
+
| `ffmpegPath` | `null` | Path to ffmpeg. `null` = look in PATH and known locations. |
|
|
195
|
+
| `keybind` | `"ctrl+y"` | Dictation shortcut. Use `"<leader>v"` if you prefer (`<leader>` = `ctrl+x`). |
|
|
196
|
+
| `autoSubmit` | `false` | `true` sends right away, skipping the review dialog. |
|
|
197
|
+
| `allowFromHome` | `true` | Allow dictating on the home screen, creating the session on the spot. |
|
|
198
|
+
| `maxSeconds` | `55` | Automatic recording cutoff. Safety ceiling is `120`. |
|
|
199
|
+
| `keepAudio` | `false` | `true` keeps the temporary WAV (debugging). |
|
|
200
|
+
| `icons` | `{ idle: "○", recording: "●", transcribing: "…" }` | Icon glyphs. Override if your font supports emoji. |
|
|
201
|
+
|
|
202
|
+
> **Why the default glyphs are `○ ● …` and not 🎤:** emoji outside the BMP (such as `🎤`) are missing
|
|
203
|
+
> from many console fonts and render as `?`; worse, their width is ambiguous, which **misaligns the
|
|
204
|
+
> clickable area** (clicks are routed by coordinate hit-testing). The default glyphs are BMP,
|
|
205
|
+
> single-width, and present in Consolas/Cascadia/DejaVu. If your terminal renders emoji correctly,
|
|
206
|
+
> use for example:
|
|
205
207
|
>
|
|
206
208
|
> ```json
|
|
207
209
|
> { "options": { "icons": { "idle": "🎤", "recording": "🔴", "transcribing": "⏳" } } }
|
|
208
210
|
> ```
|
|
209
211
|
|
|
210
|
-
|
|
212
|
+
`device` formats:
|
|
211
213
|
|
|
212
|
-
|
|
|
214
|
+
| OS | Format | Example |
|
|
213
215
|
|---|---|---|
|
|
214
|
-
| Windows |
|
|
215
|
-
| Linux |
|
|
216
|
+
| Windows | exact DirectShow name | `"Microphone (C922 Pro Stream Webcam)"` |
|
|
217
|
+
| Linux | Pulse/PipeWire source name | `"default"`, `"@DEFAULT_SOURCE@"`, `"alsa_input.pci-0000_00_1f.3.analog-stereo"` |
|
|
216
218
|
| Linux (ALSA) | `hw:CARD,DEV` | `"hw:1,0"` |
|
|
217
|
-
| macOS |
|
|
219
|
+
| macOS | numeric index | `"0"` |
|
|
218
220
|
|
|
219
221
|
---
|
|
220
222
|
|
|
221
|
-
##
|
|
223
|
+
## Usage
|
|
222
224
|
|
|
223
|
-
|
|
|
225
|
+
| Action | How |
|
|
224
226
|
|---|---|
|
|
225
|
-
|
|
|
226
|
-
|
|
|
227
|
-
|
|
|
227
|
+
| Start / stop | click the icon, or `ctrl+y`, or `/voice` |
|
|
228
|
+
| Choose microphone | command palette → **"Voice: choose microphone"** (the choice is remembered) |
|
|
229
|
+
| Send without reviewing | `"autoSubmit": true` in the options |
|
|
228
230
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
+
Icon states: `○` idle · `● Ns` recording · `…` transcribing. Errors always become a toast and never
|
|
232
|
+
freeze the TUI.
|
|
231
233
|
|
|
232
234
|
---
|
|
233
235
|
|
|
234
|
-
##
|
|
236
|
+
## Cost
|
|
235
237
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
+
`openai/whisper-large-v3` is billed **per token** (the audio is tokenized), and the price varies by
|
|
239
|
+
provider. Values read from the OpenRouter API in Oct/2026 (per 1M prompt tokens):
|
|
238
240
|
|
|
239
|
-
|
|
|
241
|
+
| Provider | Price (prompt) |
|
|
240
242
|
|---|---|
|
|
241
|
-
| DeepInfra | US$ 7
|
|
242
|
-
| Together | US$ 25
|
|
243
|
-
| Groq | US$ 30
|
|
243
|
+
| DeepInfra | US$ 7.50 / 1M tokens |
|
|
244
|
+
| Together | US$ 25.00 / 1M tokens |
|
|
245
|
+
| Groq | US$ 30.83 / 1M tokens |
|
|
244
246
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
OpenRouter routes to an available provider automatically — the real cost of each request comes back
|
|
248
|
+
in the `usage.cost` field of the response. As a rough order of magnitude, OpenRouter's own
|
|
249
|
+
documentation shows a 9.2 s transcription costing **US$ 0.000508**.
|
|
248
250
|
|
|
249
|
-
**
|
|
250
|
-
(
|
|
251
|
+
**Check the current numbers** on the model page: <https://openrouter.ai/openai/whisper-large-v3>
|
|
252
|
+
(and the spend dashboard at <https://openrouter.ai/activity>). Do not trust the figures above long
|
|
253
|
+
term.
|
|
251
254
|
|
|
252
|
-
|
|
253
|
-
`output_modalities=transcription`
|
|
255
|
+
To switch models, just change `model` in the options (browse models with
|
|
256
|
+
`output_modalities=transcription` at <https://openrouter.ai/models?output_modalities=transcription>).
|
|
254
257
|
|
|
255
258
|
---
|
|
256
259
|
|
|
257
260
|
## Troubleshooting
|
|
258
261
|
|
|
259
|
-
**
|
|
260
|
-
|
|
262
|
+
**The plugin does not show up / did not load**
|
|
263
|
+
|
|
264
|
+
Check the log (it exists on Windows too):
|
|
261
265
|
|
|
262
266
|
```
|
|
263
|
-
C:\Users\<
|
|
264
|
-
~/.local/share/opencode/log/opencode.log
|
|
267
|
+
C:\Users\<you>\.local\share\opencode\log\opencode.log # Windows
|
|
268
|
+
~/.local/share/opencode/log/opencode.log # Linux/macOS
|
|
265
269
|
```
|
|
266
270
|
|
|
267
|
-
|
|
268
|
-
|
|
271
|
+
Look for `role=cli`. Confirm the files are in `<global-config>/plugins/voice/` and restart
|
|
272
|
+
`opencode`.
|
|
273
|
+
|
|
274
|
+
**"ffmpeg not found" toast**
|
|
275
|
+
|
|
276
|
+
Install ffmpeg (commands above) or point `ffmpegPath` at it explicitly. On Windows the plugin
|
|
277
|
+
automatically descends from the Chocolatey *shim*
|
|
278
|
+
(`C:\ProgramData\chocolatey\bin\ffmpeg.exe`) to the real binary at
|
|
279
|
+
`C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin\ffmpeg.exe`, because the shim may not forward
|
|
280
|
+
the stop command to the recording process.
|
|
269
281
|
|
|
270
|
-
**"
|
|
271
|
-
Instale o ffmpeg (comandos acima) ou aponte `ffmpegPath` explicitamente. No Windows, o plugin já
|
|
272
|
-
desce automaticamente do *shim* do Chocolatey
|
|
273
|
-
(`C:\ProgramData\chocolatey\bin\ffmpeg.exe`) para o binário real em
|
|
274
|
-
`C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin\ffmpeg.exe`, porque o shim pode não repassar
|
|
275
|
-
o comando de parada ao processo de gravação.
|
|
282
|
+
**"Missing API key" toast**
|
|
276
283
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
feche e reabra. Confira com `echo $env:OPENROUTER_API_KEY` (PowerShell) ou `echo $OPENROUTER_API_KEY`.
|
|
284
|
+
The variable is not visible to the process. On Windows `setx` only applies to **new** terminals —
|
|
285
|
+
close and reopen. Check with `echo $env:OPENROUTER_API_KEY` (PowerShell) or `echo $OPENROUTER_API_KEY`.
|
|
280
286
|
|
|
281
|
-
**
|
|
282
|
-
Provavelmente o dispositivo padrão é um cabo virtual. Rode **"Voz: escolher microfone"** e selecione
|
|
283
|
-
o microfone de verdade. A heurística já evita nomes como `CABLE Output`, `Stereo Mix`, `loopback`,
|
|
284
|
-
`Virtual` e `.monitor`, mas nem todo dispositivo virtual se denuncia pelo nome.
|
|
287
|
+
**Records silence / nothing is transcribed**
|
|
285
288
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
289
|
+
The default device is probably a virtual cable. Run **"Voice: choose microphone"** and pick the real
|
|
290
|
+
microphone. The heuristic already avoids names like `CABLE Output`, `Stereo Mix`, `loopback`,
|
|
291
|
+
`Virtual` and `.monitor`, but not every virtual device gives itself away by name.
|
|
292
|
+
|
|
293
|
+
**Linux: no microphone / PulseAudio or ALSA error**
|
|
294
|
+
|
|
295
|
+
Install `pulseaudio-utils` (it provides `pactl`, used to list microphones). The plugin uses
|
|
296
|
+
`-f pulse` when it finds `pactl`, otherwise it falls back to `-f alsa` with `default`. To list
|
|
297
|
+
sources manually:
|
|
290
298
|
|
|
291
299
|
```bash
|
|
292
|
-
pactl list short sources # ignore
|
|
293
|
-
arecord -l #
|
|
300
|
+
pactl list short sources # ignore lines ending in .monitor
|
|
301
|
+
arecord -l # ALSA alternative
|
|
294
302
|
```
|
|
295
303
|
|
|
296
304
|
**WSL2**
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
305
|
+
|
|
306
|
+
Audio arrives through WSLg as a Pulse source (usually `RDPSource`). If `pactl info` fails, run
|
|
307
|
+
`wsl --shutdown` on Windows, reopen, and check the microphone permission under
|
|
308
|
+
*Settings → Privacy → Microphone*. As a last resort, set `device` explicitly.
|
|
309
|
+
|
|
310
|
+
**The icon shows as `?`** (or another odd character)
|
|
311
|
+
|
|
312
|
+
Your console font lacks the configured glyph. The default (`○ ● …`) works almost everywhere. If you
|
|
313
|
+
switched to emoji through `icons`, go back to the default or use a font with emoji support.
|
|
314
|
+
|
|
315
|
+
**I cannot click the icon**
|
|
316
|
+
|
|
317
|
+
1. Make sure the terminal sends mouse events: on **mintty/Git Bash**, check *Options → Mouse*
|
|
318
|
+
(it should be enabled). OpenTUI turns on mouse tracking at startup.
|
|
319
|
+
2. Prefer the default glyphs (`○ ● …`). Emoji have ambiguous width and shift the clickable area.
|
|
320
|
+
3. Click **right on** the glyph (there is one space of padding on each side).
|
|
321
|
+
4. Meanwhile the shortcut does exactly the same: `ctrl+y` (or `<leader>v`: `ctrl+x`, then `v`), or
|
|
322
|
+
`ctrl+p` → **"Voice: dictate"**, or `/voice`.
|
|
323
|
+
|
|
324
|
+
**Transcription fails or is slow**
|
|
325
|
+
|
|
326
|
+
The provider times out at around **60 s per request**. That is why `maxSeconds` defaults to 55. If
|
|
327
|
+
you raise it, the dialog warns that recordings over 1 minute may fail.
|
|
328
|
+
|
|
329
|
+
**The beginning of speech is lost (Windows)**
|
|
330
|
+
|
|
331
|
+
DirectShow takes about 1 s to open the device. Wait for `●` to appear, then start speaking.
|
|
320
332
|
|
|
321
333
|
---
|
|
322
334
|
|
|
323
|
-
##
|
|
335
|
+
## Development
|
|
324
336
|
|
|
325
337
|
```bash
|
|
326
338
|
npm install
|
|
327
339
|
npm run typecheck
|
|
328
340
|
```
|
|
329
341
|
|
|
330
|
-
|
|
331
|
-
|
|
342
|
+
No runtime dependencies — only Node built-ins (`child_process`, `fs`, `os`, `path`, `buffer`), which
|
|
343
|
+
work under the Bun runtime embedded in OpenCode.
|
|
332
344
|
|
|
333
|
-
|
|
345
|
+
All platform-specific logic is isolated in `src/capture.ts`. The other files contain no
|
|
334
346
|
`if (platform)`.
|
|
335
347
|
|
|
336
|
-
>
|
|
337
|
-
>
|
|
348
|
+
> Note: `@opencode/plugin` declares `engines: node >= 26.4.0`. On Node 24 `npm install` prints an
|
|
349
|
+
> `EBADENGINE` warning, harmless for typechecking.
|
|
338
350
|
|
|
339
|
-
###
|
|
351
|
+
### Layout
|
|
340
352
|
|
|
341
353
|
```
|
|
342
|
-
src/index.ts entrypoint
|
|
343
|
-
src/tui.tsx plugin: slot
|
|
344
|
-
src/capture.ts
|
|
345
|
-
src/ffmpeg.ts
|
|
354
|
+
src/index.ts server entrypoint (empty; required by the discovery layout)
|
|
355
|
+
src/tui.tsx the plugin: icon slot, shortcuts, state machine, delivery
|
|
356
|
+
src/capture.ts the ONLY module with platform-specific logic
|
|
357
|
+
src/ffmpeg.ts binary resolution, process execution, clean stop
|
|
346
358
|
src/transcribe.ts OpenRouter /audio/transcriptions
|
|
347
359
|
src/settings.ts options + env + defaults
|
|
348
360
|
```
|
|
349
361
|
|
|
350
|
-
|
|
351
|
-
|
|
362
|
+
Design decisions and the API research (what was verified and what is **not** possible) are in
|
|
363
|
+
[DESIGN.md](DESIGN.md) (currently in Portuguese).
|
|
352
364
|
|
|
353
365
|
---
|
|
354
366
|
|
|
355
|
-
##
|
|
356
|
-
|
|
357
|
-
- **
|
|
358
|
-
|
|
359
|
-
- **
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
367
|
+
## Known limitations
|
|
368
|
+
|
|
369
|
+
- **One-shot dictation:** one recording = one message. There is no continuous/streaming dictation and
|
|
370
|
+
no text-to-speech.
|
|
371
|
+
- **No direct writing into the prompt input:** V2 exposes no way — to any plugin — to insert text into
|
|
372
|
+
the composer. This was verified three ways: (1) the `UI` API has nothing for prompt/composer
|
|
373
|
+
(checked in 2.0.29 and 2.0.32); (2) the server OpenAPI has no `/tui/*` route; (3) the internal
|
|
374
|
+
`tui.prompt.append` event is consumed by the TUI (`insertText` on the composer) but **nothing
|
|
375
|
+
publishes it** — there is no HTTP endpoint and no RPC surface. V1 had `client.tui.appendPrompt`,
|
|
376
|
+
removed in V2. That is why the text goes through the review dialog (pre-filled and editable), and
|
|
377
|
+
the request to bring it back is at
|
|
366
378
|
[anomalyco/opencode#51209](https://github.com/anomalyco/opencode/issues/51209)
|
|
367
|
-
(
|
|
368
|
-
|
|
369
|
-
- **
|
|
370
|
-
|
|
371
|
-
- **Linux
|
|
372
|
-
|
|
379
|
+
(see also [#54681](https://github.com/anomalyco/opencode/issues/54681)). Once the API exists,
|
|
380
|
+
swapping the dialog for "insert into the prompt" is a small, local change.
|
|
381
|
+
- **Cloud transcription:** the audio is sent to OpenRouter and to the selected provider. Do not use it
|
|
382
|
+
with sensitive information if that is a concern.
|
|
383
|
+
- **Linux and macOS** were validated through the commands and the logic, but the end-to-end test was
|
|
384
|
+
done on Windows only. A real Linux test is still pending.
|
|
373
385
|
|
|
374
386
|
---
|
|
375
387
|
|
|
376
|
-
##
|
|
388
|
+
## Security
|
|
377
389
|
|
|
378
|
-
-
|
|
379
|
-
|
|
380
|
-
-
|
|
381
|
-
-
|
|
390
|
+
- The key comes from the environment variable or, if absent, from OpenCode's own store (read-only).
|
|
391
|
+
It is never logged, copied or persisted by the plugin.
|
|
392
|
+
- The WAV is written to `os.tmpdir()` and deleted after transcription (unless `keepAudio: true`).
|
|
393
|
+
- A recording in progress is stopped when the plugin is unloaded.
|
|
382
394
|
|
|
383
395
|
---
|
|
384
396
|
|
|
385
|
-
##
|
|
397
|
+
## License
|
|
386
398
|
|
|
387
|
-
MIT —
|
|
399
|
+
MIT — see [LICENSE](LICENSE).
|
package/README.pt-BR.md
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# opencode-voice-plugin
|
|
2
|
+
|
|
3
|
+
[English](README.md) · **Português (BR)**
|
|
4
|
+
|
|
5
|
+
Ditado por voz para o **OpenCode V2**: um ícone de microfone (`○`) no rodapé do prompt. Clique nele
|
|
6
|
+
(ou use `ctrl+y`), fale, e o texto transcrito vira uma mensagem da sessão.
|
|
7
|
+
|
|
8
|
+
A transcrição usa o **OpenRouter** (`POST /api/v1/audio/transcriptions`) e a chave é reaproveitada do
|
|
9
|
+
próprio OpenCode quando você já tem o OpenRouter conectado — sem variável de ambiente e sem duplicar
|
|
10
|
+
segredo. Funciona em **Windows, Linux e macOS** a partir do mesmo código, e é instalado **na máquina**
|
|
11
|
+
(não por projeto).
|
|
12
|
+
|
|
13
|
+
[Repositório](https://github.com/kabrau/opencode-voice-plugin) ·
|
|
14
|
+
[Issues](https://github.com/kabrau/opencode-voice-plugin/issues) ·
|
|
15
|
+
[Design](DESIGN.md) · MIT
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Como funciona
|
|
20
|
+
|
|
21
|
+
1. `○` no rodapé do prompt (aparece dentro de uma sessão).
|
|
22
|
+
2. Clique, ou `ctrl+y`, ou digite `/voice` → começa a gravar (`●` + segundos).
|
|
23
|
+
3. Clique de novo (ou espere `maxSeconds`) → para, transcreve (`…`).
|
|
24
|
+
4. Abre o dialog **Ditado** com o texto — edite se quiser e confirme → a mensagem é enviada na sessão.
|
|
25
|
+
|
|
26
|
+
Na **tela inicial** também funciona: como ali ainda não existe sessão, o `ctrl+y` **cria a sessão na
|
|
27
|
+
hora** (antes de gravar) e o texto é enviado nela — o mesmo que digitar e dar Enter. Desligue com
|
|
28
|
+
`"allowFromHome": false` se preferir que o microfone só aja dentro de uma sessão.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Requisitos
|
|
33
|
+
|
|
34
|
+
- OpenCode **V2** (`opencode --version`).
|
|
35
|
+
- **ffmpeg** no sistema:
|
|
36
|
+
- Windows: `choco install ffmpeg`
|
|
37
|
+
- Debian/Ubuntu: `sudo apt install ffmpeg pulseaudio-utils`
|
|
38
|
+
- macOS: `brew install ffmpeg`
|
|
39
|
+
- Uma chave de API do **OpenRouter** (veja [Custo](#custo) e [Chave da API](#chave-da-api)).
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Instalação (via npm — recomendado)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
opencode plugin add opencode-voice-plugin
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Pronto — sem clonar nem copiar arquivo. Atualizar e remover:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
opencode plugin update opencode-voice-plugin
|
|
53
|
+
opencode plugin remove opencode-voice-plugin
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
> Para **configurar options** (ícones, atalho, dispositivo, etc.), veja
|
|
57
|
+
> [Onde declarar as options](#onde-declarar-as-options) — é um detalhe que o `plugin add`
|
|
58
|
+
> sozinho não cobre.
|
|
59
|
+
|
|
60
|
+
### Sem npm (direto do GitHub)
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
opencode plugin add github:kabrau/opencode-voice-plugin
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Instalação manual (na máquina)
|
|
69
|
+
|
|
70
|
+
Se preferir cópia local, o OpenCode descobre plugins no diretório global de configuração:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
<config-global>/plugins/<nome>/index.ts # entrypoint de servidor
|
|
74
|
+
<config-global>/plugins/<nome>/tui.tsx # entrypoint da TUI (o plugin)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
No Windows o config global fica em `C:\Users\<você>\.config\opencode`. No Linux/macOS, em
|
|
78
|
+
`~/.config/opencode` (ou `$XDG_CONFIG_HOME/opencode`).
|
|
79
|
+
|
|
80
|
+
### Windows (PowerShell)
|
|
81
|
+
|
|
82
|
+
```powershell
|
|
83
|
+
git clone https://github.com/kabrau/opencode-voice-plugin.git
|
|
84
|
+
$destino = "$env:USERPROFILE\.config\opencode\plugins\voice"
|
|
85
|
+
New-Item -ItemType Directory -Force -Path $destino | Out-Null
|
|
86
|
+
Copy-Item .\opencode-voice-plugin\src\* $destino
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Linux / macOS
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
git clone https://github.com/kabrau/opencode-voice-plugin.git
|
|
93
|
+
destino="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/voice"
|
|
94
|
+
mkdir -p "$destino"
|
|
95
|
+
cp opencode-voice-plugin/src/* "$destino/"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Depois, **reinicie o `opencode`**.
|
|
99
|
+
|
|
100
|
+
### Instalação alternativa (apontando para o clone)
|
|
101
|
+
|
|
102
|
+
Aponte o `cli.json` global para o repositório clonado. Isso mantém o plugin atualizado via `git pull`
|
|
103
|
+
e é o jeito de passar options, já que a pasta de descoberta não tem onde declará-las:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"plugins": [
|
|
108
|
+
{
|
|
109
|
+
"package": "file:///caminho/para/opencode-voice-plugin",
|
|
110
|
+
"options": {
|
|
111
|
+
"language": "pt",
|
|
112
|
+
"keybind": "ctrl+y"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
> O caminho pode ser um caminho absoluto ou uma URL `file://`. **Adicione** ao array `plugins`
|
|
120
|
+
> existente em `~/.config/opencode/cli.json`, sem apagar o resto do arquivo.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Chave da API
|
|
125
|
+
|
|
126
|
+
O plugin resolve a chave nesta ordem:
|
|
127
|
+
|
|
128
|
+
1. **Variável de ambiente** — `OPENROUTER_API_KEY` (ou o nome em `apiKeyEnv`), se existir.
|
|
129
|
+
2. **Store do próprio OpenCode** — se você já conectou o OpenRouter, o plugin usa essa chave
|
|
130
|
+
automaticamente. **Nada a configurar.**
|
|
131
|
+
|
|
132
|
+
Ou seja: se o OpenRouter já aparece conectado, você **não precisa** criar variável nenhuma.
|
|
133
|
+
Confira com:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
opencode auth list # deve listar "OpenRouter ... stored"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Se preferir a env var (ou não tiver o OpenRouter conectado):
|
|
140
|
+
|
|
141
|
+
```powershell
|
|
142
|
+
setx OPENROUTER_API_KEY "sk-or-..." # Windows; reabra o terminal depois
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
export OPENROUTER_API_KEY="sk-or-..." # Linux/macOS (~/.bashrc / ~/.zshrc)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Gere uma chave em <https://openrouter.ai/settings/keys>. Nos dois casos a chave **nunca** é
|
|
150
|
+
logada, copiada nem gravada pelo plugin — e a leitura do store é somente-leitura, do arquivo
|
|
151
|
+
que o próprio OpenCode mantém (`<data>/opencode/auth.json`).
|
|
152
|
+
|
|
153
|
+
Para uma postura "só env var", use `"apiKeyFromAuth": false` nas options; para ler de outra
|
|
154
|
+
integração (ex.: a do Console), ajuste `authIntegration`.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Configuração
|
|
159
|
+
|
|
160
|
+
### Onde declarar as options
|
|
161
|
+
|
|
162
|
+
As options são lidas do **`cli.json`** (config global), **não** do `opencode.jsonc`. Isso vale
|
|
163
|
+
mesmo que você tenha instalado por cópia em `plugins/voice/`: adicione a entrada abaixo ao array
|
|
164
|
+
`plugins` do `~/.config/opencode/cli.json`, preservando o resto do arquivo.
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"plugins": [
|
|
169
|
+
{
|
|
170
|
+
"package": "C:\\Users\\<você>\\.config\\opencode\\plugins\\voice",
|
|
171
|
+
"options": {
|
|
172
|
+
"autoSubmit": true
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
> Verificado nas duas direções: com essa entrada, `context.options` recebe o objeto; declarando as
|
|
180
|
+
> mesmas options no array `plugins` do **`opencode.jsonc`**, o componente de TUI recebe `{}`.
|
|
181
|
+
> Não há duplicação do plugin por estar também descoberto na pasta global.
|
|
182
|
+
|
|
183
|
+
Todas as options são opcionais.
|
|
184
|
+
|
|
185
|
+
| Option | Default | Descrição |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `apiKeyEnv` | `"OPENROUTER_API_KEY"` | Nome da variável de ambiente com a chave. Tem prioridade sobre o store. |
|
|
188
|
+
| `apiKeyFromAuth` | `true` | Se a env var não existir, usa a chave já guardada pelo OpenCode. |
|
|
189
|
+
| `authIntegration` | `"openrouter"` | Integração do OpenCode de onde ler a chave. |
|
|
190
|
+
| `model` | `"openai/whisper-large-v3"` | Modelo de STT do OpenRouter. |
|
|
191
|
+
| `language` | `"auto"` | Código ISO-639-1, ou `"auto"` para detecção automática. |
|
|
192
|
+
| `baseUrl` | `"https://openrouter.ai/api/v1"` | Base do provedor compatível com `/audio/transcriptions`. |
|
|
193
|
+
| `device` | `null` | Microfone. `null` = autodetectar. Formatos por SO abaixo. |
|
|
194
|
+
| `ffmpegPath` | `null` | Caminho do ffmpeg. `null` = procurar no PATH e em locais conhecidos. |
|
|
195
|
+
| `keybind` | `"ctrl+y"` | Atalho do ditado. Use `"<leader>v"` se preferir (`<leader>` = `ctrl+x`). |
|
|
196
|
+
| `autoSubmit` | `false` | `true` envia direto, sem o dialog de revisão. |
|
|
197
|
+
| `allowFromHome` | `true` | Permite ditar na tela inicial, criando a sessão na hora. |
|
|
198
|
+
| `maxSeconds` | `55` | Corte automático da gravação. Teto de segurança em `120`. |
|
|
199
|
+
| `keepAudio` | `false` | `true` mantém o WAV temporário (debug). |
|
|
200
|
+
| `icons` | `{ idle: "○", recording: "●", transcribing: "…" }` | Glifos do ícone. Sobrescreva se sua fonte suportar emoji. |
|
|
201
|
+
|
|
202
|
+
> **Por que os glifos padrão são `○ ● …` e não 🎤:** emojis fora do BMP (como `🎤`) não
|
|
203
|
+
> existem na fonte de muitos consoles e aparecem como `?`; além disso a largura deles é
|
|
204
|
+
> ambígua, o que **desalinha a área clicável** (o clique roteia por hit-test de coordenadas).
|
|
205
|
+
> Os glifos padrão são do BMP, largura 1 e existem em Consolas/Cascadia/DejaVu. Se o seu
|
|
206
|
+
> terminal renderiza emoji corretamente, use por exemplo:
|
|
207
|
+
>
|
|
208
|
+
> ```json
|
|
209
|
+
> { "options": { "icons": { "idle": "🎤", "recording": "🔴", "transcribing": "⏳" } } }
|
|
210
|
+
> ```
|
|
211
|
+
|
|
212
|
+
Formatos de `device`:
|
|
213
|
+
|
|
214
|
+
| SO | Formato | Exemplo |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| Windows | nome exato do DirectShow | `"Microphone (C922 Pro Stream Webcam)"` |
|
|
217
|
+
| Linux | nome do source Pulse/PipeWire | `"default"`, `"@DEFAULT_SOURCE@"`, `"alsa_input.pci-0000_00_1f.3.analog-stereo"` |
|
|
218
|
+
| Linux (ALSA) | `hw:CARD,DEV` | `"hw:1,0"` |
|
|
219
|
+
| macOS | índice numérico | `"0"` |
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Uso
|
|
224
|
+
|
|
225
|
+
| Ação | Como |
|
|
226
|
+
|---|---|
|
|
227
|
+
| Iniciar / parar | clique no ícone, ou `ctrl+y`, ou `/voice` |
|
|
228
|
+
| Escolher microfone | command palette → **"Voice: choose microphone"** (a escolha fica salva) |
|
|
229
|
+
| Enviar sem revisar | `"autoSubmit": true` nas options |
|
|
230
|
+
|
|
231
|
+
Estados do ícone: `○` ocioso · `● Ns` gravando · `…` transcrevendo. Erros sempre viram um toast,
|
|
232
|
+
nunca travam a TUI.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Custo
|
|
237
|
+
|
|
238
|
+
O `openai/whisper-large-v3` é cobrado **por token** (o áudio é tokenizado), e o preço varia por
|
|
239
|
+
provedor. Valores consultados na API do OpenRouter em out/2026 (por 1M de tokens de prompt):
|
|
240
|
+
|
|
241
|
+
| Provedor | Preço (prompt) |
|
|
242
|
+
|---|---|
|
|
243
|
+
| DeepInfra | US$ 7,50 / 1M tokens |
|
|
244
|
+
| Together | US$ 25,00 / 1M tokens |
|
|
245
|
+
| Groq | US$ 30,83 / 1M tokens |
|
|
246
|
+
|
|
247
|
+
O OpenRouter roteia automaticamente para um provedor disponível — o custo real de cada requisição
|
|
248
|
+
vem no campo `usage.cost` da resposta. Como ordem de grandeza, o exemplo da própria documentação do
|
|
249
|
+
OpenRouter mostra uma transcrição de 9,2 s custando **US$ 0,000508**.
|
|
250
|
+
|
|
251
|
+
**Confira os valores atuais** na página do modelo: <https://openrouter.ai/openai/whisper-large-v3>
|
|
252
|
+
(e o painel de gastos em <https://openrouter.ai/activity>). Não confie nos números acima a longo prazo.
|
|
253
|
+
|
|
254
|
+
Para trocar de modelo, basta mudar `model` nas options (veja os modelos com
|
|
255
|
+
`output_modalities=transcription` em <https://openrouter.ai/models?output_modalities=transcription>).
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Troubleshooting
|
|
260
|
+
|
|
261
|
+
**O plugin não aparece / não carregou**
|
|
262
|
+
Verifique o log (existe mesmo no Windows):
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
C:\Users\<você>\.local\share\opencode\log\opencode.log # Windows
|
|
266
|
+
~/.local/share/opencode/log/opencode.log # Linux/macOS
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Procure por `role=cli`. Confirme que os arquivos estão em `<config-global>/plugins/voice/` e
|
|
270
|
+
reinicie o `opencode`.
|
|
271
|
+
|
|
272
|
+
**"ffmpeg não encontrado" (toast)**
|
|
273
|
+
Instale o ffmpeg (comandos acima) ou aponte `ffmpegPath` explicitamente. No Windows, o plugin já
|
|
274
|
+
desce automaticamente do *shim* do Chocolatey
|
|
275
|
+
(`C:\ProgramData\chocolatey\bin\ffmpeg.exe`) para o binário real em
|
|
276
|
+
`C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin\ffmpeg.exe`, porque o shim pode não repassar
|
|
277
|
+
o comando de parada ao processo de gravação.
|
|
278
|
+
|
|
279
|
+
**"Chave da API ausente" (toast)**
|
|
280
|
+
A variável não está visível para o processo. No Windows, `setx` só vale para **novos** terminais —
|
|
281
|
+
feche e reabra. Confira com `echo $env:OPENROUTER_API_KEY` (PowerShell) ou `echo $OPENROUTER_API_KEY`.
|
|
282
|
+
|
|
283
|
+
**Grava silêncio / nada é transcrito**
|
|
284
|
+
Provavelmente o dispositivo padrão é um cabo virtual. Rode **"Voice: choose microphone"** e selecione
|
|
285
|
+
o microfone de verdade. A heurística já evita nomes como `CABLE Output`, `Stereo Mix`, `loopback`,
|
|
286
|
+
`Virtual` e `.monitor`, mas nem todo dispositivo virtual se denuncia pelo nome.
|
|
287
|
+
|
|
288
|
+
**Linux: sem microfone / erro do PulseAudio ou ALSA**
|
|
289
|
+
Instale `pulseaudio-utils` (fornece o `pactl`, usado para listar os microfones). O plugin usa
|
|
290
|
+
`-f pulse` quando encontra o `pactl`, senão cai para `-f alsa` com `default`. Para listar os sources
|
|
291
|
+
manualmente:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
pactl list short sources # ignore linhas terminando em .monitor
|
|
295
|
+
arecord -l # alternativa via ALSA
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
**WSL2**
|
|
299
|
+
O áudio chega pelo WSLg como um source do Pulse (normalmente `RDPSource`). Se `pactl info` falhar,
|
|
300
|
+
rode `wsl --shutdown` no Windows, reabra e verifique a permissão de microfone em
|
|
301
|
+
*Configurações → Privacidade → Microfone*. Em último caso, use `device` explícito.
|
|
302
|
+
|
|
303
|
+
**O ícone aparece como `?`** (ou outro caractere estranho)
|
|
304
|
+
Sua fonte de console não tem o glifo configurado. O padrão (`○ ● …`) funciona na maioria.
|
|
305
|
+
Se você trocou por emoji via `icons`, volte para o padrão ou use uma fonte com emoji.
|
|
306
|
+
|
|
307
|
+
**Não consigo clicar no ícone**
|
|
308
|
+
1. Confirme que o terminal envia eventos de mouse: no **mintty/Git Bash**, veja
|
|
309
|
+
*Options → Mouse* (deve estar habilitado). O OpenTUI liga o mouse tracking ao iniciar.
|
|
310
|
+
2. Prefira os glifos padrão (`○ ● …`). Emoji têm largura ambígua e deslocam a área clicável.
|
|
311
|
+
3. Clique **em cima** do glifo (há um espaço de folga de cada lado).
|
|
312
|
+
4. Enquanto isso, o atalho faz exatamente o mesmo: `ctrl+y` (ou `<leader>v`: `ctrl+x`, depois `v`), ou
|
|
313
|
+
`ctrl+p` → **"Voice: dictate"**, ou `/voice`.
|
|
314
|
+
|
|
315
|
+
**A transcrição falha ou demora**
|
|
316
|
+
O provedor estoura em cerca de **60 s por requisição**. Por isso `maxSeconds` é 55 por padrão. Se você
|
|
317
|
+
aumentar, o dialog avisa que gravações acima de 1 minuto podem falhar.
|
|
318
|
+
|
|
319
|
+
**O começo da fala se perde (Windows)**
|
|
320
|
+
O DirectShow leva cerca de 1 s para abrir o dispositivo. Espere o `●` aparecer e só então comece a
|
|
321
|
+
falar.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Desenvolvimento
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
npm install
|
|
329
|
+
npm run typecheck
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Sem dependências de runtime — apenas módulos nativos do Node (`child_process`, `fs`, `os`, `path`,
|
|
333
|
+
`buffer`), que funcionam sob o Bun embutido no OpenCode.
|
|
334
|
+
|
|
335
|
+
Toda a lógica por plataforma está isolada em `src/capture.ts`. Os demais arquivos não contêm
|
|
336
|
+
`if (platform)`.
|
|
337
|
+
|
|
338
|
+
> Observação: `@opencode/plugin` declara `engines: node >= 26.4.0`. No Node 24 o `npm install` emite
|
|
339
|
+
> um aviso `EBADENGINE`, inofensivo para o typecheck.
|
|
340
|
+
|
|
341
|
+
### Estrutura
|
|
342
|
+
|
|
343
|
+
```
|
|
344
|
+
src/index.ts entrypoint de servidor (vazio, exigido pelo layout de descoberta)
|
|
345
|
+
src/tui.tsx plugin: slot do ícone, atalhos, máquina de estados, envio
|
|
346
|
+
src/capture.ts ÚNICO módulo com lógica por plataforma
|
|
347
|
+
src/ffmpeg.ts resolução do binário, execução, parada limpa
|
|
348
|
+
src/transcribe.ts OpenRouter /audio/transcriptions
|
|
349
|
+
src/settings.ts options + env + defaults
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
As decisões de design e a pesquisa de API (o que foi verificado e o que **não** é possível)
|
|
353
|
+
estão em [DESIGN.md](DESIGN.md).
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Limitações conhecidas
|
|
358
|
+
|
|
359
|
+
- **Ditado avulso:** uma gravação = uma mensagem. Não há ditado contínuo/streaming nem leitura das
|
|
360
|
+
respostas em voz alta (TTS).
|
|
361
|
+
- **Sem escrita direta no input do prompt:** a V2 não expõe — a nenhum plugin — um meio de inserir
|
|
362
|
+
texto no campo do prompt. Isso foi verificado em três frentes: (1) a API `UI` não tem nada de
|
|
363
|
+
prompt/composer (nem na 2.0.29 nem na 2.0.32); (2) o OpenAPI do servidor não tem rota `/tui/*`;
|
|
364
|
+
(3) o evento interno `tui.prompt.append` é consumido pela TUI (`insertText` no composer), mas
|
|
365
|
+
**nada o publica** — não há endpoint HTTP nem superfície RPC. A V1 tinha
|
|
366
|
+
`client.tui.appendPrompt`, removido na V2. Por isso o texto passa pelo dialog de revisão
|
|
367
|
+
(já preenchido e editável), e o pedido de reabrir isso está em
|
|
368
|
+
[anomalyco/opencode#51209](https://github.com/anomalyco/opencode/issues/51209)
|
|
369
|
+
(veja também [#54681](https://github.com/anomalyco/opencode/issues/54681)). Quando a API existir,
|
|
370
|
+
trocar o dialog por "inserir no prompt" é uma mudança pequena e localizada.
|
|
371
|
+
- **Transcrição na nuvem:** o áudio é enviado ao OpenRouter e ao provedor escolhido. Não use com
|
|
372
|
+
informação sensível se isso for um problema.
|
|
373
|
+
- **Linux e macOS** foram validados pelos comandos e pela lógica, mas o teste ponta a ponta foi feito
|
|
374
|
+
apenas no Windows. Teste real em Linux: pendente.
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## Segurança
|
|
379
|
+
|
|
380
|
+
- A chave vem da variável de ambiente ou, se ela não existir, do store do próprio OpenCode
|
|
381
|
+
(leitura somente-leitura). Nunca é logada, copiada nem persistida pelo plugin.
|
|
382
|
+
- O WAV é gravado em `os.tmpdir()` e apagado depois de transcrever (salvo `keepAudio: true`).
|
|
383
|
+
- Uma gravação em andamento é encerrada quando o plugin é descarregado.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Licença
|
|
388
|
+
|
|
389
|
+
MIT — veja [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-voice-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Voice dictation for OpenCode V2: click a mic in the prompt footer, speak, and send the transcribed text.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"src",
|
|
19
19
|
"README.md",
|
|
20
|
+
"README.pt-BR.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|
|
22
23
|
"repository": {
|
package/src/capture.ts
CHANGED
|
@@ -79,12 +79,12 @@ const CONTROL: ProcessControl = captureControl()
|
|
|
79
79
|
|
|
80
80
|
export function installHint(): string {
|
|
81
81
|
if (PLATFORM === "win32") {
|
|
82
|
-
return "
|
|
82
|
+
return "Install ffmpeg with `choco install ffmpeg` (or download it from ffmpeg.org and add it to PATH)."
|
|
83
83
|
}
|
|
84
84
|
if (PLATFORM === "darwin") {
|
|
85
|
-
return "
|
|
85
|
+
return "Install ffmpeg with `brew install ffmpeg`."
|
|
86
86
|
}
|
|
87
|
-
return "
|
|
87
|
+
return "Install ffmpeg with `sudo apt install ffmpeg pulseaudio-utils` (Debian/Ubuntu) or the equivalent for your distro."
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
export interface CaptureBackend {
|
|
@@ -173,7 +173,7 @@ export function pickDefaultDevice(devices: readonly string[]): string | undefine
|
|
|
173
173
|
|
|
174
174
|
function windowsInputArgs(device?: string): string[] {
|
|
175
175
|
if (!device) {
|
|
176
|
-
throw new Error("
|
|
176
|
+
throw new Error("On Windows you must pick a DirectShow microphone before recording.")
|
|
177
177
|
}
|
|
178
178
|
return ["-f", "dshow", "-i", `audio=${device}`]
|
|
179
179
|
}
|
package/src/ffmpeg.ts
CHANGED
|
@@ -89,7 +89,7 @@ export function resolveFfmpeg(explicit: string | null | undefined, control: Proc
|
|
|
89
89
|
return Promise.resolve(resolved)
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
return Promise.reject(new Error("ffmpeg
|
|
92
|
+
return Promise.reject(new Error("ffmpeg not found"))
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
export interface RecordingResult {
|
package/src/settings.ts
CHANGED
package/src/transcribe.ts
CHANGED
|
@@ -75,14 +75,14 @@ export async function transcribe(input: TranscribeInput): Promise<TranscribeOutp
|
|
|
75
75
|
|
|
76
76
|
const raw = await response.text()
|
|
77
77
|
if (!response.ok) {
|
|
78
|
-
throw new TranscribeError(`
|
|
78
|
+
throw new TranscribeError(`The provider returned ${response.status}.`, response.status, truncate(raw))
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
let parsed: { text?: unknown; usage?: { cost?: unknown; seconds?: unknown } }
|
|
82
82
|
try {
|
|
83
83
|
parsed = JSON.parse(raw) as typeof parsed
|
|
84
84
|
} catch {
|
|
85
|
-
throw new TranscribeError("
|
|
85
|
+
throw new TranscribeError("Invalid response from the provider.", response.status, truncate(raw))
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
const text = typeof parsed.text === "string" ? parsed.text.trim() : ""
|
|
@@ -92,7 +92,7 @@ export async function transcribe(input: TranscribeInput): Promise<TranscribeOutp
|
|
|
92
92
|
} catch (error) {
|
|
93
93
|
if (error instanceof TranscribeError) throw error
|
|
94
94
|
if (error instanceof Error && error.name === "AbortError") {
|
|
95
|
-
throw new TranscribeError("
|
|
95
|
+
throw new TranscribeError("Timed out talking to the provider.")
|
|
96
96
|
}
|
|
97
97
|
throw new TranscribeError(toMessage(error))
|
|
98
98
|
} finally {
|
package/src/tui.tsx
CHANGED
|
@@ -45,7 +45,7 @@ export default Plugin.define({
|
|
|
45
45
|
let inFlightStop: Promise<void> | undefined
|
|
46
46
|
let activeSessionID: string | undefined
|
|
47
47
|
|
|
48
|
-
const notify = (message: string, variant: Variant = "info", title = "
|
|
48
|
+
const notify = (message: string, variant: Variant = "info", title = "Voice"): void => {
|
|
49
49
|
context.ui.toast.show({ title, message, variant })
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -83,7 +83,7 @@ export default Plugin.define({
|
|
|
83
83
|
return ffmpegPath
|
|
84
84
|
} catch {
|
|
85
85
|
setReady(false)
|
|
86
|
-
notify(`ffmpeg
|
|
86
|
+
notify(`ffmpeg not found. ${installHint()}`, "error")
|
|
87
87
|
return undefined
|
|
88
88
|
}
|
|
89
89
|
}
|
|
@@ -113,7 +113,7 @@ export default Plugin.define({
|
|
|
113
113
|
const info = await context.client.session.create({ location: context.location ?? null })
|
|
114
114
|
sessionID = info.id
|
|
115
115
|
} catch (error) {
|
|
116
|
-
notify(failure(error), "error", "
|
|
116
|
+
notify(failure(error), "error", "Could not create the session")
|
|
117
117
|
return undefined
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -128,39 +128,39 @@ export default Plugin.define({
|
|
|
128
128
|
async function deliver(text: string): Promise<void> {
|
|
129
129
|
const sessionID = currentSessionID() ?? activeSessionID
|
|
130
130
|
if (!sessionID) {
|
|
131
|
-
notify("
|
|
131
|
+
notify("Open a session to dictate.", "warning")
|
|
132
132
|
return
|
|
133
133
|
}
|
|
134
134
|
if (settings.autoSubmit) {
|
|
135
135
|
await context.client.session.prompt({ sessionID, text })
|
|
136
|
-
notify("
|
|
136
|
+
notify("Dictation sent.", "success")
|
|
137
137
|
return
|
|
138
138
|
}
|
|
139
139
|
const description =
|
|
140
140
|
settings.maxSeconds > PROVIDER_SAFE_SECONDS
|
|
141
|
-
? "
|
|
142
|
-
: "
|
|
143
|
-
const edited = await context.ui.dialog.prompt({ title: "
|
|
141
|
+
? "Recordings over 1 minute may fail at the provider. Review and edit before sending."
|
|
142
|
+
: "Review and edit before sending."
|
|
143
|
+
const edited = await context.ui.dialog.prompt({ title: "Dictation", description, value: text })
|
|
144
144
|
const finalText = edited?.trim()
|
|
145
145
|
if (!finalText) return
|
|
146
146
|
await context.client.session.prompt({ sessionID, text: finalText })
|
|
147
|
-
notify("
|
|
147
|
+
notify("Dictation sent.", "success")
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
async function start(): Promise<void> {
|
|
151
151
|
const { key } = resolveApiKey(settings)
|
|
152
152
|
if (!key) {
|
|
153
153
|
notify(
|
|
154
|
-
`
|
|
154
|
+
`OpenRouter API key missing. Connect with "opencode auth login" or set the ${settings.apiKeyEnv} environment variable.`,
|
|
155
155
|
"error",
|
|
156
|
-
"
|
|
156
|
+
"Missing API key",
|
|
157
157
|
)
|
|
158
158
|
return
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
const sessionID = await ensureSession()
|
|
162
162
|
if (!sessionID) {
|
|
163
|
-
notify("
|
|
163
|
+
notify("Open a session to dictate.", "warning")
|
|
164
164
|
return
|
|
165
165
|
}
|
|
166
166
|
activeSessionID = sessionID
|
|
@@ -186,7 +186,7 @@ export default Plugin.define({
|
|
|
186
186
|
file,
|
|
187
187
|
]
|
|
188
188
|
} catch (error) {
|
|
189
|
-
notify(failure(error), "error", "
|
|
189
|
+
notify(failure(error), "error", "Microphone")
|
|
190
190
|
return
|
|
191
191
|
}
|
|
192
192
|
|
|
@@ -214,13 +214,13 @@ export default Plugin.define({
|
|
|
214
214
|
const result = await current.stop()
|
|
215
215
|
if (result.code !== 0 && result.code !== null) {
|
|
216
216
|
const detail = result.stderr.trim()
|
|
217
|
-
notify(`ffmpeg
|
|
217
|
+
notify(`ffmpeg exited with code ${result.code}.${detail ? ` ${detail}` : ""}`, "error", "Recording failed")
|
|
218
218
|
return
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
const { key } = resolveApiKey(settings)
|
|
222
222
|
if (!key) {
|
|
223
|
-
notify(`
|
|
223
|
+
notify(`Set the ${settings.apiKeyEnv} environment variable or connect OpenRouter.`, "error", "Missing API key")
|
|
224
224
|
return
|
|
225
225
|
}
|
|
226
226
|
|
|
@@ -235,7 +235,7 @@ export default Plugin.define({
|
|
|
235
235
|
setState("idle")
|
|
236
236
|
|
|
237
237
|
if (!text) {
|
|
238
|
-
notify("
|
|
238
|
+
notify("Nothing was recognized in the audio. Try speaking closer to the microphone.", "warning")
|
|
239
239
|
return
|
|
240
240
|
}
|
|
241
241
|
|
|
@@ -285,18 +285,18 @@ export default Plugin.define({
|
|
|
285
285
|
try {
|
|
286
286
|
devices = await backend.listDevices(ffmpeg)
|
|
287
287
|
} catch (error) {
|
|
288
|
-
notify(failure(error), "error", "
|
|
288
|
+
notify(failure(error), "error", "Microphone")
|
|
289
289
|
return
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
if (devices.length === 0) {
|
|
293
|
-
notify("
|
|
293
|
+
notify("No microphone found.", "warning")
|
|
294
294
|
return
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
const current = settings.device ?? (deviceStore.name || undefined)
|
|
298
298
|
const choice = await context.ui.dialog.select({
|
|
299
|
-
title: "
|
|
299
|
+
title: "Microphone",
|
|
300
300
|
current,
|
|
301
301
|
options: devices.map((device) => ({ title: device, value: device })),
|
|
302
302
|
})
|
|
@@ -305,7 +305,7 @@ export default Plugin.define({
|
|
|
305
305
|
await setDeviceStore((draft) => {
|
|
306
306
|
draft.name = choice
|
|
307
307
|
})
|
|
308
|
-
notify(`
|
|
308
|
+
notify(`Microphone: ${choice}`, "success")
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
const iconColor = (): RGBA => {
|
|
@@ -349,9 +349,9 @@ export default Plugin.define({
|
|
|
349
349
|
commands: [
|
|
350
350
|
{
|
|
351
351
|
id: "voice.mic.toggle",
|
|
352
|
-
title: "
|
|
353
|
-
description: "
|
|
354
|
-
group: "
|
|
352
|
+
title: "Voice: dictate",
|
|
353
|
+
description: "Record from the microphone and transcribe it into the session",
|
|
354
|
+
group: "Voice",
|
|
355
355
|
bind: settings.keybind,
|
|
356
356
|
palette: true,
|
|
357
357
|
slash: { name: "voice" },
|
|
@@ -361,9 +361,9 @@ export default Plugin.define({
|
|
|
361
361
|
},
|
|
362
362
|
{
|
|
363
363
|
id: "voice.mic.device",
|
|
364
|
-
title: "
|
|
365
|
-
description: "
|
|
366
|
-
group: "
|
|
364
|
+
title: "Voice: choose microphone",
|
|
365
|
+
description: "List microphones and remember the chosen one",
|
|
366
|
+
group: "Voice",
|
|
367
367
|
palette: true,
|
|
368
368
|
run: () => {
|
|
369
369
|
void chooseDevice()
|