opencode-voice-plugin 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +398 -2
- package/README.pt-BR.md +389 -0
- package/package.json +72 -4
- package/src/capture.ts +253 -0
- package/src/ffmpeg.ts +157 -0
- package/src/index.ts +11 -0
- package/src/settings.ts +162 -0
- package/src/transcribe.ts +111 -0
- package/src/tui.tsx +391 -0
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin de ditado por voz para o OpenCode V2.
|
|
3
|
+
*
|
|
4
|
+
* Coloca um ícone de microfone no rodapé do prompt. Clique (ou atalho) inicia a
|
|
5
|
+
* gravação; o segundo clique para, transcreve via OpenRouter e abre um dialog
|
|
6
|
+
* para revisão antes de enviar o texto como mensagem da sessão.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
10
|
+
import { createSignal } from "solid-js"
|
|
11
|
+
import { tmpdir } from "node:os"
|
|
12
|
+
import { join } from "node:path"
|
|
13
|
+
import { rm } from "node:fs/promises"
|
|
14
|
+
import type { RGBA } from "@opentui/core"
|
|
15
|
+
import { captureControl, getBackend, installHint } from "./capture.js"
|
|
16
|
+
import { resolveFfmpeg, startRecording, type Recording } from "./ffmpeg.js"
|
|
17
|
+
import { PROVIDER_SAFE_SECONDS, resolveApiKey, resolveSettings } from "./settings.js"
|
|
18
|
+
import { TranscribeError, transcribe } from "./transcribe.js"
|
|
19
|
+
|
|
20
|
+
type State = "idle" | "recording" | "transcribing"
|
|
21
|
+
type Variant = "info" | "success" | "warning" | "error"
|
|
22
|
+
|
|
23
|
+
export default Plugin.define({
|
|
24
|
+
id: "voice.mic",
|
|
25
|
+
async setup(context) {
|
|
26
|
+
const settings = resolveSettings(context.options)
|
|
27
|
+
const backend = getBackend()
|
|
28
|
+
const control = captureControl()
|
|
29
|
+
|
|
30
|
+
const [state, setState] = createSignal<State>("idle")
|
|
31
|
+
const [elapsed, setElapsed] = createSignal(0)
|
|
32
|
+
const [hovered, setHovered] = createSignal(false)
|
|
33
|
+
const [ready, setReady] = createSignal(true)
|
|
34
|
+
|
|
35
|
+
const [deviceStore, setDeviceStore] = context.storage.store<{ name: string }>("voice.device", {
|
|
36
|
+
initial: { name: "" },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
let ffmpegPath: string | undefined
|
|
40
|
+
let recording: Recording | undefined
|
|
41
|
+
let ticker: ReturnType<typeof setInterval> | undefined
|
|
42
|
+
let starting = false
|
|
43
|
+
let stopping = false
|
|
44
|
+
let delivering = false
|
|
45
|
+
let inFlightStop: Promise<void> | undefined
|
|
46
|
+
let activeSessionID: string | undefined
|
|
47
|
+
|
|
48
|
+
const notify = (message: string, variant: Variant = "info", title = "Voice"): void => {
|
|
49
|
+
context.ui.toast.show({ title, message, variant })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const failure = (error: unknown): string => {
|
|
53
|
+
if (error instanceof TranscribeError && error.body) return `${error.message} ${error.body}`
|
|
54
|
+
return error instanceof Error ? error.message : String(error)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function currentSessionID(): string | undefined {
|
|
58
|
+
const route = context.ui.router.current()
|
|
59
|
+
return route.type === "session" ? route.sessionID : undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clearTicker(): void {
|
|
63
|
+
if (ticker) {
|
|
64
|
+
clearInterval(ticker)
|
|
65
|
+
ticker = undefined
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function discard(file: string): Promise<void> {
|
|
70
|
+
if (settings.keepAudio) return
|
|
71
|
+
try {
|
|
72
|
+
await rm(file, { force: true })
|
|
73
|
+
} catch {
|
|
74
|
+
// arquivo já removido
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function ensureFfmpeg(): Promise<string | undefined> {
|
|
79
|
+
if (ffmpegPath) return ffmpegPath
|
|
80
|
+
try {
|
|
81
|
+
ffmpegPath = await resolveFfmpeg(settings.ffmpegPath, control)
|
|
82
|
+
setReady(true)
|
|
83
|
+
return ffmpegPath
|
|
84
|
+
} catch {
|
|
85
|
+
setReady(false)
|
|
86
|
+
notify(`ffmpeg not found. ${installHint()}`, "error")
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resolveDevice(ffmpeg: string): Promise<string | undefined> {
|
|
92
|
+
if (settings.device) return settings.device
|
|
93
|
+
if (deviceStore.name) return deviceStore.name
|
|
94
|
+
try {
|
|
95
|
+
return await backend.defaultDevice(ffmpeg)
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Sessão alvo. Na tela inicial não existe `sessionID`; ali ditar equivale a mandar a
|
|
103
|
+
* primeira mensagem, então criamos a sessão na hora. Se `navigate` falhar, a sessão
|
|
104
|
+
* já existe e seguimos com ela — navegar é conveniência, não pré-requisito.
|
|
105
|
+
*/
|
|
106
|
+
async function ensureSession(): Promise<string | undefined> {
|
|
107
|
+
const existing = currentSessionID()
|
|
108
|
+
if (existing) return existing
|
|
109
|
+
if (!settings.allowFromHome) return undefined
|
|
110
|
+
|
|
111
|
+
let sessionID: string
|
|
112
|
+
try {
|
|
113
|
+
const info = await context.client.session.create({ location: context.location ?? null })
|
|
114
|
+
sessionID = info.id
|
|
115
|
+
} catch (error) {
|
|
116
|
+
notify(failure(error), "error", "Could not create the session")
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
context.ui.router.navigate({ type: "session", sessionID })
|
|
122
|
+
} catch {
|
|
123
|
+
// sem navegação, mas a sessão existe
|
|
124
|
+
}
|
|
125
|
+
return sessionID
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function deliver(text: string): Promise<void> {
|
|
129
|
+
const sessionID = currentSessionID() ?? activeSessionID
|
|
130
|
+
if (!sessionID) {
|
|
131
|
+
notify("Open a session to dictate.", "warning")
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (settings.autoSubmit) {
|
|
135
|
+
await context.client.session.prompt({ sessionID, text })
|
|
136
|
+
notify("Dictation sent.", "success")
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
const description =
|
|
140
|
+
settings.maxSeconds > PROVIDER_SAFE_SECONDS
|
|
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
|
+
const finalText = edited?.trim()
|
|
145
|
+
if (!finalText) return
|
|
146
|
+
await context.client.session.prompt({ sessionID, text: finalText })
|
|
147
|
+
notify("Dictation sent.", "success")
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function start(): Promise<void> {
|
|
151
|
+
const { key } = resolveApiKey(settings)
|
|
152
|
+
if (!key) {
|
|
153
|
+
notify(
|
|
154
|
+
`OpenRouter API key missing. Connect with "opencode auth login" or set the ${settings.apiKeyEnv} environment variable.`,
|
|
155
|
+
"error",
|
|
156
|
+
"Missing API key",
|
|
157
|
+
)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sessionID = await ensureSession()
|
|
162
|
+
if (!sessionID) {
|
|
163
|
+
notify("Open a session to dictate.", "warning")
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
activeSessionID = sessionID
|
|
167
|
+
|
|
168
|
+
const ffmpeg = await ensureFfmpeg()
|
|
169
|
+
if (!ffmpeg) return
|
|
170
|
+
|
|
171
|
+
const device = await resolveDevice(ffmpeg)
|
|
172
|
+
const file = join(tmpdir(), `opencode-voice-${Date.now()}.wav`)
|
|
173
|
+
|
|
174
|
+
let args: string[]
|
|
175
|
+
try {
|
|
176
|
+
args = [
|
|
177
|
+
"-hide_banner",
|
|
178
|
+
"-loglevel",
|
|
179
|
+
"error",
|
|
180
|
+
...backend.inputArgs(device),
|
|
181
|
+
"-ac",
|
|
182
|
+
"1",
|
|
183
|
+
"-ar",
|
|
184
|
+
"16000",
|
|
185
|
+
"-y",
|
|
186
|
+
file,
|
|
187
|
+
]
|
|
188
|
+
} catch (error) {
|
|
189
|
+
notify(failure(error), "error", "Microphone")
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
recording = startRecording({ ffmpeg, args, file, control })
|
|
194
|
+
setElapsed(0)
|
|
195
|
+
setState("recording")
|
|
196
|
+
|
|
197
|
+
ticker = setInterval(() => {
|
|
198
|
+
setElapsed((value) => value + 1)
|
|
199
|
+
if (elapsed() >= settings.maxSeconds) void stop().catch(() => {})
|
|
200
|
+
}, 1_000)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function stop(): Promise<void> {
|
|
204
|
+
if (stopping || state() !== "recording" || !recording) return
|
|
205
|
+
stopping = true
|
|
206
|
+
clearTicker()
|
|
207
|
+
|
|
208
|
+
const current = recording
|
|
209
|
+
recording = undefined
|
|
210
|
+
setState("transcribing")
|
|
211
|
+
|
|
212
|
+
const run = (async () => {
|
|
213
|
+
try {
|
|
214
|
+
const result = await current.stop()
|
|
215
|
+
if (result.code !== 0 && result.code !== null) {
|
|
216
|
+
const detail = result.stderr.trim()
|
|
217
|
+
notify(`ffmpeg exited with code ${result.code}.${detail ? ` ${detail}` : ""}`, "error", "Recording failed")
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { key } = resolveApiKey(settings)
|
|
222
|
+
if (!key) {
|
|
223
|
+
notify(`Set the ${settings.apiKeyEnv} environment variable or connect OpenRouter.`, "error", "Missing API key")
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const { text } = await transcribe({
|
|
228
|
+
file: current.file,
|
|
229
|
+
apiKey: key,
|
|
230
|
+
model: settings.model,
|
|
231
|
+
language: settings.language,
|
|
232
|
+
baseUrl: settings.baseUrl,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
setState("idle")
|
|
236
|
+
|
|
237
|
+
if (!text) {
|
|
238
|
+
notify("Nothing was recognized in the audio. Try speaking closer to the microphone.", "warning")
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
delivering = true
|
|
243
|
+
try {
|
|
244
|
+
await deliver(text)
|
|
245
|
+
} finally {
|
|
246
|
+
delivering = false
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
notify(failure(error), "error")
|
|
250
|
+
} finally {
|
|
251
|
+
setState("idle")
|
|
252
|
+
stopping = false
|
|
253
|
+
inFlightStop = undefined
|
|
254
|
+
activeSessionID = undefined
|
|
255
|
+
await discard(current.file)
|
|
256
|
+
}
|
|
257
|
+
})()
|
|
258
|
+
|
|
259
|
+
inFlightStop = run
|
|
260
|
+
await run
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function toggle(): Promise<void> {
|
|
264
|
+
if (delivering || state() === "transcribing") return
|
|
265
|
+
if (state() === "recording") {
|
|
266
|
+
await stop()
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
if (starting) return
|
|
270
|
+
starting = true
|
|
271
|
+
try {
|
|
272
|
+
await start()
|
|
273
|
+
} catch (error) {
|
|
274
|
+
notify(failure(error), "error")
|
|
275
|
+
} finally {
|
|
276
|
+
starting = false
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function chooseDevice(): Promise<void> {
|
|
281
|
+
const ffmpeg = await ensureFfmpeg()
|
|
282
|
+
if (!ffmpeg) return
|
|
283
|
+
|
|
284
|
+
let devices: string[]
|
|
285
|
+
try {
|
|
286
|
+
devices = await backend.listDevices(ffmpeg)
|
|
287
|
+
} catch (error) {
|
|
288
|
+
notify(failure(error), "error", "Microphone")
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (devices.length === 0) {
|
|
293
|
+
notify("No microphone found.", "warning")
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const current = settings.device ?? (deviceStore.name || undefined)
|
|
298
|
+
const choice = await context.ui.dialog.select({
|
|
299
|
+
title: "Microphone",
|
|
300
|
+
current,
|
|
301
|
+
options: devices.map((device) => ({ title: device, value: device })),
|
|
302
|
+
})
|
|
303
|
+
if (!choice) return
|
|
304
|
+
|
|
305
|
+
await setDeviceStore((draft) => {
|
|
306
|
+
draft.name = choice
|
|
307
|
+
})
|
|
308
|
+
notify(`Microphone: ${choice}`, "success")
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const iconColor = (): RGBA => {
|
|
312
|
+
if (state() === "recording") return context.theme.text.feedback.error.base
|
|
313
|
+
if (state() === "transcribing") return context.theme.text.feedback.warning.base
|
|
314
|
+
if (!ready()) return context.theme.text.muted
|
|
315
|
+
if (hovered()) return context.theme.text.action.primary.state({ hovered: true })
|
|
316
|
+
return context.theme.text.base
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const iconLabel = (showDetails: boolean): string => {
|
|
320
|
+
const icons = settings.icons
|
|
321
|
+
if (state() === "recording") {
|
|
322
|
+
return showDetails ? ` ${icons.recording} ${elapsed()}s ` : ` ${icons.recording} `
|
|
323
|
+
}
|
|
324
|
+
if (state() === "transcribing") return ` ${icons.transcribing} `
|
|
325
|
+
return ` ${icons.idle} `
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const unregisterSlot = context.ui.slot({
|
|
329
|
+
append: "prompt.footer",
|
|
330
|
+
render: (input) => {
|
|
331
|
+
if (input.mode === "shell") return null
|
|
332
|
+
return (
|
|
333
|
+
<text
|
|
334
|
+
fg={iconColor()}
|
|
335
|
+
onMouseOver={() => setHovered(true)}
|
|
336
|
+
onMouseOut={() => setHovered(false)}
|
|
337
|
+
onMouseDown={() => {
|
|
338
|
+
void toggle()
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
{iconLabel(input.showDetails)}
|
|
342
|
+
</text>
|
|
343
|
+
)
|
|
344
|
+
},
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
context.keymap.layer(() => ({
|
|
348
|
+
mode: "global",
|
|
349
|
+
commands: [
|
|
350
|
+
{
|
|
351
|
+
id: "voice.mic.toggle",
|
|
352
|
+
title: "Voice: dictate",
|
|
353
|
+
description: "Record from the microphone and transcribe it into the session",
|
|
354
|
+
group: "Voice",
|
|
355
|
+
bind: settings.keybind,
|
|
356
|
+
palette: true,
|
|
357
|
+
slash: { name: "voice" },
|
|
358
|
+
run: () => {
|
|
359
|
+
void toggle()
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: "voice.mic.device",
|
|
364
|
+
title: "Voice: choose microphone",
|
|
365
|
+
description: "List microphones and remember the chosen one",
|
|
366
|
+
group: "Voice",
|
|
367
|
+
palette: true,
|
|
368
|
+
run: () => {
|
|
369
|
+
void chooseDevice()
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
}))
|
|
374
|
+
|
|
375
|
+
return async () => {
|
|
376
|
+
clearTicker()
|
|
377
|
+
unregisterSlot()
|
|
378
|
+
if (inFlightStop) await inFlightStop.catch(() => {})
|
|
379
|
+
if (recording) {
|
|
380
|
+
const current = recording
|
|
381
|
+
recording = undefined
|
|
382
|
+
try {
|
|
383
|
+
await current.stop()
|
|
384
|
+
} catch {
|
|
385
|
+
// processo já encerrado
|
|
386
|
+
}
|
|
387
|
+
await discard(current.file)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
})
|