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/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
+ })