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/capture.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ÚNICO módulo com lógica por plataforma.
|
|
3
|
+
*
|
|
4
|
+
* Aqui vivem: montagem dos argumentos de entrada do ffmpeg, listagem de dispositivos
|
|
5
|
+
* e a heurística de microfone padrão. Também expõe `ProcessControl`, que injeta no
|
|
6
|
+
* `ffmpeg.ts` tudo que é específico do sistema (caminhos conhecidos, como matar a
|
|
7
|
+
* árvore de processos, etc.), mantendo o resto do código livre de `if (platform)`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process"
|
|
11
|
+
import { existsSync, statSync } from "node:fs"
|
|
12
|
+
import { findOnPath, runCommand } from "./ffmpeg.js"
|
|
13
|
+
|
|
14
|
+
export const PLATFORM: NodeJS.Platform = process.platform
|
|
15
|
+
|
|
16
|
+
/** Um arquivo abaixo disso é tratado como launcher/shim, não como o ffmpeg real. */
|
|
17
|
+
const SHIM_MAX_BYTES = 1_000_000
|
|
18
|
+
|
|
19
|
+
const WINDOWS_FFMPEG_PATHS = [
|
|
20
|
+
"C:\\ProgramData\\chocolatey\\lib\\ffmpeg\\tools\\ffmpeg\\bin\\ffmpeg.exe",
|
|
21
|
+
"C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe",
|
|
22
|
+
"C:\\ffmpeg\\bin\\ffmpeg.exe",
|
|
23
|
+
]
|
|
24
|
+
const DARWIN_FFMPEG_PATHS = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"]
|
|
25
|
+
const LINUX_FFMPEG_PATHS = ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/snap/bin/ffmpeg"]
|
|
26
|
+
|
|
27
|
+
function pathExtensions(): readonly string[] {
|
|
28
|
+
return PLATFORM === "win32" ? [".exe", ".cmd", ".bat", ""] : [""]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isShim(file: string): boolean {
|
|
32
|
+
if (PLATFORM !== "win32") return false
|
|
33
|
+
try {
|
|
34
|
+
const stats = statSync(file)
|
|
35
|
+
return stats.isFile() && stats.size < SHIM_MAX_BYTES
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ProcessControl {
|
|
42
|
+
readonly knownPaths: readonly string[]
|
|
43
|
+
readonly pathExtensions: readonly string[]
|
|
44
|
+
readonly spawnDetached: boolean
|
|
45
|
+
isShim(file: string): boolean
|
|
46
|
+
killTree(pid: number): Promise<void>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function captureControl(): ProcessControl {
|
|
50
|
+
return {
|
|
51
|
+
knownPaths: PLATFORM === "win32" ? WINDOWS_FFMPEG_PATHS : PLATFORM === "darwin" ? DARWIN_FFMPEG_PATHS : LINUX_FFMPEG_PATHS,
|
|
52
|
+
pathExtensions: pathExtensions(),
|
|
53
|
+
spawnDetached: PLATFORM !== "win32",
|
|
54
|
+
isShim,
|
|
55
|
+
async killTree(pid: number) {
|
|
56
|
+
if (!pid) return
|
|
57
|
+
if (PLATFORM === "win32") {
|
|
58
|
+
await new Promise<void>((resolve) => {
|
|
59
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true })
|
|
60
|
+
killer.on("close", () => resolve())
|
|
61
|
+
killer.on("error", () => resolve())
|
|
62
|
+
})
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
process.kill(-pid, "SIGKILL")
|
|
67
|
+
} catch {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, "SIGKILL")
|
|
70
|
+
} catch {
|
|
71
|
+
// já morreu
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const CONTROL: ProcessControl = captureControl()
|
|
79
|
+
|
|
80
|
+
export function installHint(): string {
|
|
81
|
+
if (PLATFORM === "win32") {
|
|
82
|
+
return "Install ffmpeg with `choco install ffmpeg` (or download it from ffmpeg.org and add it to PATH)."
|
|
83
|
+
}
|
|
84
|
+
if (PLATFORM === "darwin") {
|
|
85
|
+
return "Install ffmpeg with `brew install ffmpeg`."
|
|
86
|
+
}
|
|
87
|
+
return "Install ffmpeg with `sudo apt install ffmpeg pulseaudio-utils` (Debian/Ubuntu) or the equivalent for your distro."
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface CaptureBackend {
|
|
91
|
+
/** argumentos de entrada do ffmpeg para o dispositivo (nome ou id) */
|
|
92
|
+
inputArgs(device?: string): string[]
|
|
93
|
+
/** lista de dispositivos legíveis (para log e para o caso de múltiplos microfones) */
|
|
94
|
+
listDevices(ffmpeg: string): Promise<string[]>
|
|
95
|
+
/** dispositivo usado quando o usuário não configura `device` */
|
|
96
|
+
defaultDevice(ffmpeg: string): Promise<string | undefined>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Parsing (puro, testável)
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/** `"Microphone (C922)" (audio)` → `Microphone (C922)`; ignora dispositivos de vídeo. */
|
|
104
|
+
export function parseDshowDevices(text: string): string[] {
|
|
105
|
+
const devices: string[] = []
|
|
106
|
+
const pattern = /"([^"]+)"\s*\((?:audio|áudio)\)/g
|
|
107
|
+
let match: RegExpExecArray | null
|
|
108
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
109
|
+
if (match[1]) devices.push(match[1].trim())
|
|
110
|
+
}
|
|
111
|
+
return devices
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `pactl list short sources`: nome na 2ª coluna; descarta monitores de saída. */
|
|
115
|
+
export function parsePactlSources(text: string): string[] {
|
|
116
|
+
return text
|
|
117
|
+
.split(/\r?\n/)
|
|
118
|
+
.map((line) => line.split("\t"))
|
|
119
|
+
.filter((columns) => columns.length >= 2 && columns[1] !== undefined)
|
|
120
|
+
.map((columns) => (columns[1] ?? "").trim())
|
|
121
|
+
.filter((name) => name.length > 0 && !name.endsWith(".monitor"))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `arecord -l`: `card 1: PCH [...], device 0: ...` → `hw:1,0`. */
|
|
125
|
+
export function parseArecordDevices(text: string): string[] {
|
|
126
|
+
const devices: string[] = []
|
|
127
|
+
const pattern = /card\s+(\d+):[\s\S]*?device\s+(\d+):/g
|
|
128
|
+
let match: RegExpExecArray | null
|
|
129
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
130
|
+
devices.push(`hw:${match[1]},${match[2]}`)
|
|
131
|
+
}
|
|
132
|
+
return devices
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `ffmpeg -f avfoundation -list_devices`: lista os índices do bloco de áudio. */
|
|
136
|
+
export function parseAvfoundationDevices(text: string): string[] {
|
|
137
|
+
const marker = "AVFoundation audio devices:"
|
|
138
|
+
const index = text.indexOf(marker)
|
|
139
|
+
if (index < 0) return []
|
|
140
|
+
const section = text.slice(index)
|
|
141
|
+
const devices: string[] = []
|
|
142
|
+
const pattern = /\[(\d+)\]\s+(.+)/g
|
|
143
|
+
let match: RegExpExecArray | null
|
|
144
|
+
while ((match = pattern.exec(section)) !== null) {
|
|
145
|
+
if (match[1]) devices.push(match[1].trim())
|
|
146
|
+
}
|
|
147
|
+
return devices
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Heurística de dispositivo padrão
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
const VIRTUAL = [/cable output/i, /stereo mix/i, /what u hear/i, /\.monitor\b/i, /loopback/i, /virtual/i]
|
|
155
|
+
const PREFERRED = [/microphone/i, /microfone/i, /headset/i, /mic\b/i, /^default$/i, /@default_source@/i]
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Muitas máquinas têm cabos virtuais (VB-Audio, MSI Sound Tune, monitores do Pulse)
|
|
159
|
+
* que abrem mas gravam silêncio. Preferimos nomes de microfone de verdade e evitamos
|
|
160
|
+
* os virtuais; sem candidato ideal, usamos o primeiro não virtual.
|
|
161
|
+
*/
|
|
162
|
+
export function pickDefaultDevice(devices: readonly string[]): string | undefined {
|
|
163
|
+
const list = devices.map((device) => device.trim()).filter(Boolean)
|
|
164
|
+
if (list.length === 0) return undefined
|
|
165
|
+
const clean = list.filter((name) => !VIRTUAL.some((pattern) => pattern.test(name)))
|
|
166
|
+
const pool = clean.length > 0 ? clean : list
|
|
167
|
+
return pool.find((name) => PREFERRED.some((pattern) => pattern.test(name))) ?? pool[0]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Backends por plataforma
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
function windowsInputArgs(device?: string): string[] {
|
|
175
|
+
if (!device) {
|
|
176
|
+
throw new Error("On Windows you must pick a DirectShow microphone before recording.")
|
|
177
|
+
}
|
|
178
|
+
return ["-f", "dshow", "-i", `audio=${device}`]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function windowsListDevices(ffmpeg: string): Promise<string[]> {
|
|
182
|
+
// O ffmpeg sai com código != 0 aqui de propósito; a lista vem no stderr.
|
|
183
|
+
const { stderr } = await runCommand(
|
|
184
|
+
ffmpeg,
|
|
185
|
+
["-hide_banner", "-f", "dshow", "-list_devices", "true", "-i", "dummy"],
|
|
186
|
+
CONTROL,
|
|
187
|
+
)
|
|
188
|
+
return parseDshowDevices(stderr)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const windowsBackend: CaptureBackend = {
|
|
192
|
+
inputArgs: windowsInputArgs,
|
|
193
|
+
listDevices: windowsListDevices,
|
|
194
|
+
defaultDevice: async (ffmpeg) => pickDefaultDevice(await windowsListDevices(ffmpeg)),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let pactlCache: boolean | undefined
|
|
198
|
+
|
|
199
|
+
function hasPactl(): boolean {
|
|
200
|
+
if (pactlCache === undefined) {
|
|
201
|
+
pactlCache = findOnPath("pactl", pathExtensions()) !== undefined
|
|
202
|
+
}
|
|
203
|
+
return pactlCache
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function linuxInputArgs(device?: string): string[] {
|
|
207
|
+
const driver = hasPactl() ? "pulse" : "alsa"
|
|
208
|
+
return ["-f", driver, "-i", device ?? "default"]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function linuxListDevices(_ffmpeg: string): Promise<string[]> {
|
|
212
|
+
if (hasPactl()) {
|
|
213
|
+
const { stdout } = await runCommand("pactl", ["list", "short", "sources"], CONTROL)
|
|
214
|
+
const sources = parsePactlSources(stdout)
|
|
215
|
+
if (sources.length > 0) return sources
|
|
216
|
+
}
|
|
217
|
+
const { stdout } = await runCommand("arecord", ["-l"], CONTROL)
|
|
218
|
+
return parseArecordDevices(stdout)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const linuxBackend: CaptureBackend = {
|
|
222
|
+
inputArgs: linuxInputArgs,
|
|
223
|
+
listDevices: linuxListDevices,
|
|
224
|
+
defaultDevice: async (ffmpeg) => pickDefaultDevice(await linuxListDevices(ffmpeg)),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function macInputArgs(device?: string): string[] {
|
|
228
|
+
return ["-f", "avfoundation", "-i", `:${device ?? "0"}`]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function macListDevices(ffmpeg: string): Promise<string[]> {
|
|
232
|
+
const { stderr } = await runCommand(
|
|
233
|
+
ffmpeg,
|
|
234
|
+
["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
|
|
235
|
+
CONTROL,
|
|
236
|
+
)
|
|
237
|
+
return parseAvfoundationDevices(stderr)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const macBackend: CaptureBackend = {
|
|
241
|
+
inputArgs: macInputArgs,
|
|
242
|
+
listDevices: macListDevices,
|
|
243
|
+
defaultDevice: async (ffmpeg) => pickDefaultDevice(await macListDevices(ffmpeg)),
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const fallbackBackend: CaptureBackend = linuxBackend
|
|
247
|
+
|
|
248
|
+
export function getBackend(): CaptureBackend {
|
|
249
|
+
if (PLATFORM === "win32") return windowsBackend
|
|
250
|
+
if (PLATFORM === "darwin") return macBackend
|
|
251
|
+
if (existsSync("/proc") || PLATFORM === "linux") return linuxBackend
|
|
252
|
+
return fallbackBackend
|
|
253
|
+
}
|
package/src/ffmpeg.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolução do binário do ffmpeg, execução de processos e parada limpa.
|
|
3
|
+
* Este arquivo é intencionalmente livre de lógica por plataforma: tudo que muda
|
|
4
|
+
* entre sistemas vem injetado via `ProcessControl` (definido em `capture.ts`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process"
|
|
8
|
+
import { existsSync } from "node:fs"
|
|
9
|
+
import { delimiter, join } from "node:path"
|
|
10
|
+
import type { ProcessControl } from "./capture.js"
|
|
11
|
+
|
|
12
|
+
export interface CommandResult {
|
|
13
|
+
code: number | null
|
|
14
|
+
stdout: string
|
|
15
|
+
stderr: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Procura um executável no PATH. Usa o separador de PATH do próprio Node, sem branch de plataforma. */
|
|
19
|
+
export function findOnPath(name: string, extensions: readonly string[]): string | undefined {
|
|
20
|
+
const entries = (process.env.PATH ?? "").split(delimiter)
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (!entry) continue
|
|
23
|
+
for (const extension of extensions) {
|
|
24
|
+
const candidate = join(entry, name + extension)
|
|
25
|
+
if (existsSync(candidate)) return candidate
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Executa um comando e captura stdout/stderr. Nunca rejeita: o resultado carrega o erro. */
|
|
32
|
+
export function runCommand(
|
|
33
|
+
command: string,
|
|
34
|
+
args: readonly string[],
|
|
35
|
+
control: ProcessControl,
|
|
36
|
+
timeoutMs = 15_000,
|
|
37
|
+
): Promise<CommandResult> {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
let stdout = ""
|
|
40
|
+
let stderr = ""
|
|
41
|
+
const child = spawn(command, [...args], { windowsHide: true })
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
void control.killTree(child.pid ?? 0)
|
|
44
|
+
}, timeoutMs)
|
|
45
|
+
|
|
46
|
+
const finish = (code: number | null) => {
|
|
47
|
+
clearTimeout(timer)
|
|
48
|
+
resolve({ code, stdout, stderr })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
52
|
+
stdout += chunk.toString()
|
|
53
|
+
})
|
|
54
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
55
|
+
stderr += chunk.toString()
|
|
56
|
+
})
|
|
57
|
+
child.on("close", (code) => finish(code))
|
|
58
|
+
child.on("error", (error) => {
|
|
59
|
+
stderr += (stderr ? "\n" : "") + error.message
|
|
60
|
+
finish(null)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ffmpegCache = new Map<string, string>()
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Ordem de resolução: opção explícita → PATH → caminhos conhecidos da plataforma.
|
|
69
|
+
* Se o caminho resolvido for um launcher (ex.: shim do Chocolatey no Windows),
|
|
70
|
+
* desce para o binário real, para que `q` no stdin chegue ao processo certo.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveFfmpeg(explicit: string | null | undefined, control: ProcessControl): Promise<string> {
|
|
73
|
+
const key = explicit ?? ""
|
|
74
|
+
const cached = ffmpegCache.get(key)
|
|
75
|
+
if (cached && existsSync(cached)) return Promise.resolve(cached)
|
|
76
|
+
|
|
77
|
+
const candidates: string[] = []
|
|
78
|
+
if (explicit) candidates.push(explicit)
|
|
79
|
+
const onPath = findOnPath("ffmpeg", control.pathExtensions)
|
|
80
|
+
if (onPath) candidates.push(onPath)
|
|
81
|
+
candidates.push(...control.knownPaths)
|
|
82
|
+
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
if (!existsSync(candidate)) continue
|
|
85
|
+
const resolved = control.isShim(candidate)
|
|
86
|
+
? (control.knownPaths.find((path) => existsSync(path) && !control.isShim(path)) ?? candidate)
|
|
87
|
+
: candidate
|
|
88
|
+
ffmpegCache.set(key, resolved)
|
|
89
|
+
return Promise.resolve(resolved)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Promise.reject(new Error("ffmpeg not found"))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface RecordingResult {
|
|
96
|
+
code: number | null
|
|
97
|
+
stderr: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface Recording {
|
|
101
|
+
readonly file: string
|
|
102
|
+
/** Escreve `q` no stdin para o ffmpeg fechar o WAV; mata a árvore após 2 s se preciso. */
|
|
103
|
+
stop(): Promise<RecordingResult>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function startRecording(options: {
|
|
107
|
+
ffmpeg: string
|
|
108
|
+
args: readonly string[]
|
|
109
|
+
file: string
|
|
110
|
+
control: ProcessControl
|
|
111
|
+
}): Recording {
|
|
112
|
+
const { ffmpeg, args, file, control } = options
|
|
113
|
+
const child = spawn(ffmpeg, [...args], {
|
|
114
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
detached: control.spawnDetached,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
let stderr = ""
|
|
120
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
121
|
+
if (stderr.length < 8_000) stderr += chunk.toString()
|
|
122
|
+
})
|
|
123
|
+
// O stdin pode fechar sozinho se o ffmpeg morrer durante a gravação.
|
|
124
|
+
child.stdin?.on("error", () => {})
|
|
125
|
+
|
|
126
|
+
const exited = new Promise<RecordingResult>((resolve) => {
|
|
127
|
+
child.on("close", (code) => resolve({ code, stderr }))
|
|
128
|
+
child.on("error", (error) => {
|
|
129
|
+
stderr += (stderr ? "\n" : "") + error.message
|
|
130
|
+
resolve({ code: null, stderr })
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
let stopping: Promise<RecordingResult> | undefined
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
file,
|
|
138
|
+
stop() {
|
|
139
|
+
if (stopping) return stopping
|
|
140
|
+
stopping = (async () => {
|
|
141
|
+
try {
|
|
142
|
+
child.stdin?.write("q\n")
|
|
143
|
+
child.stdin?.end()
|
|
144
|
+
} catch {
|
|
145
|
+
// stdin já estava fechado
|
|
146
|
+
}
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
void control.killTree(child.pid ?? 0)
|
|
149
|
+
}, 2_000)
|
|
150
|
+
const result = await exited
|
|
151
|
+
clearTimeout(timer)
|
|
152
|
+
return result
|
|
153
|
+
})()
|
|
154
|
+
return stopping
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Entrypoint de servidor exigido pelo layout de descoberta de plugins
|
|
5
|
+
* (`index.ts` + `tui` no mesmo diretório). Toda a funcionalidade vive no `tui.tsx`;
|
|
6
|
+
* este plugin é intencionalmente vazio.
|
|
7
|
+
*/
|
|
8
|
+
export default Plugin.define({
|
|
9
|
+
id: "voice.server",
|
|
10
|
+
setup() {},
|
|
11
|
+
})
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuração do plugin: options do cli.json + variáveis de ambiente + defaults.
|
|
3
|
+
*
|
|
4
|
+
* Resolução da chave da API, nesta ordem:
|
|
5
|
+
* 1. `process.env[apiKeyEnv]`
|
|
6
|
+
* 2. store de credenciais do próprio OpenCode (`auth.json`), se `apiKeyFromAuth`
|
|
7
|
+
*
|
|
8
|
+
* A chave nunca é logada, copiada nem gravada em nenhum lugar.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs"
|
|
12
|
+
import { homedir } from "node:os"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Glifos do ícone. Os defaults são caracteres do BMP com largura 1, que existem
|
|
17
|
+
* nas fontes de console (Consolas, Cascadia, DejaVu) e são medidos corretamente —
|
|
18
|
+
* emojis fora do BMP (ex.: 🎤) aparecem como "?" em alguns terminais e desalinham
|
|
19
|
+
* a área clicável. Se sua fonte suportar emoji, sobrescreva via `icons`.
|
|
20
|
+
*/
|
|
21
|
+
export interface VoiceIcons {
|
|
22
|
+
idle: string
|
|
23
|
+
recording: string
|
|
24
|
+
transcribing: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_ICONS: VoiceIcons = {
|
|
28
|
+
idle: "○",
|
|
29
|
+
recording: "●",
|
|
30
|
+
transcribing: "…",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface VoiceSettings {
|
|
34
|
+
/** Nome da variável de ambiente que guarda a chave do OpenRouter (prioridade). */
|
|
35
|
+
apiKeyEnv: string
|
|
36
|
+
/** Se a env var não existir, usa a chave já guardada pelo OpenCode. */
|
|
37
|
+
apiKeyFromAuth: boolean
|
|
38
|
+
/** Integração do OpenCode de onde ler a chave (ex.: "openrouter"). */
|
|
39
|
+
authIntegration: string
|
|
40
|
+
/** Modelo de STT (ex.: "openai/whisper-large-v3"). */
|
|
41
|
+
model: string
|
|
42
|
+
/** Código ISO-639-1 ou "auto" (omite o campo no request). */
|
|
43
|
+
language: string
|
|
44
|
+
/** Base URL do provedor compatível com /audio/transcriptions. */
|
|
45
|
+
baseUrl: string
|
|
46
|
+
/** Dispositivo de áudio explícito; null = autodetectar. */
|
|
47
|
+
device: string | null
|
|
48
|
+
/** Caminho explícito do binário ffmpeg; null = resolver automaticamente. */
|
|
49
|
+
ffmpegPath: string | null
|
|
50
|
+
/** Binding do comando de ditado (ex.: "ctrl+y" ou "<leader>v"). */
|
|
51
|
+
keybind: string
|
|
52
|
+
/** Envia direto, sem abrir o dialog de revisão. */
|
|
53
|
+
autoSubmit: boolean
|
|
54
|
+
/** Permite ditar na tela inicial, criando a sessão automaticamente. */
|
|
55
|
+
allowFromHome: boolean
|
|
56
|
+
/** Corte automático da gravação, em segundos. */
|
|
57
|
+
maxSeconds: number
|
|
58
|
+
/** Mantém o WAV temporário depois de transcrever (debug). */
|
|
59
|
+
keepAudio: boolean
|
|
60
|
+
/** Glifos do ícone por estado. */
|
|
61
|
+
icons: VoiceIcons
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const DEFAULTS: VoiceSettings = {
|
|
65
|
+
apiKeyEnv: "OPENROUTER_API_KEY",
|
|
66
|
+
apiKeyFromAuth: true,
|
|
67
|
+
authIntegration: "openrouter",
|
|
68
|
+
model: "openai/whisper-large-v3",
|
|
69
|
+
language: "auto",
|
|
70
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
71
|
+
device: null,
|
|
72
|
+
ffmpegPath: null,
|
|
73
|
+
keybind: "ctrl+y",
|
|
74
|
+
autoSubmit: false,
|
|
75
|
+
allowFromHome: true,
|
|
76
|
+
maxSeconds: 55,
|
|
77
|
+
keepAudio: false,
|
|
78
|
+
icons: DEFAULT_ICONS,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* O provedor de STT estoura em ~60 s por requisição, então uma gravação mais longa
|
|
83
|
+
* que isso provavelmente falha. Mantemos o teto em 120 s apenas como rede de segurança.
|
|
84
|
+
*/
|
|
85
|
+
export const MAX_SECONDS_CEILING = 120
|
|
86
|
+
export const PROVIDER_SAFE_SECONDS = 60
|
|
87
|
+
|
|
88
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
89
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveSettings(options: Readonly<Record<string, unknown>> | undefined): VoiceSettings {
|
|
93
|
+
const raw = options ?? {}
|
|
94
|
+
const settings: VoiceSettings = { ...DEFAULTS }
|
|
95
|
+
|
|
96
|
+
settings.apiKeyEnv = nonEmptyString(raw.apiKeyEnv) ?? settings.apiKeyEnv
|
|
97
|
+
settings.apiKeyFromAuth = raw.apiKeyFromAuth !== false
|
|
98
|
+
settings.authIntegration = nonEmptyString(raw.authIntegration) ?? settings.authIntegration
|
|
99
|
+
settings.model = nonEmptyString(raw.model) ?? settings.model
|
|
100
|
+
settings.language = nonEmptyString(raw.language) ?? settings.language
|
|
101
|
+
settings.baseUrl = nonEmptyString(raw.baseUrl) ?? settings.baseUrl
|
|
102
|
+
settings.device = nonEmptyString(raw.device) ?? null
|
|
103
|
+
settings.ffmpegPath = nonEmptyString(raw.ffmpegPath) ?? null
|
|
104
|
+
settings.keybind = nonEmptyString(raw.keybind) ?? settings.keybind
|
|
105
|
+
settings.autoSubmit = raw.autoSubmit === true
|
|
106
|
+
settings.allowFromHome = raw.allowFromHome !== false
|
|
107
|
+
settings.keepAudio = raw.keepAudio === true
|
|
108
|
+
|
|
109
|
+
const rawIcons = raw.icons as Partial<Record<keyof VoiceIcons, unknown>> | undefined
|
|
110
|
+
if (rawIcons && typeof rawIcons === "object") {
|
|
111
|
+
settings.icons = {
|
|
112
|
+
idle: nonEmptyString(rawIcons.idle) ?? DEFAULT_ICONS.idle,
|
|
113
|
+
recording: nonEmptyString(rawIcons.recording) ?? DEFAULT_ICONS.recording,
|
|
114
|
+
transcribing: nonEmptyString(rawIcons.transcribing) ?? DEFAULT_ICONS.transcribing,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof raw.maxSeconds === "number" && Number.isFinite(raw.maxSeconds) && raw.maxSeconds > 0) {
|
|
119
|
+
settings.maxSeconds = Math.min(Math.floor(raw.maxSeconds), MAX_SECONDS_CEILING)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return settings
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Diretório de dados do OpenCode. Layout fixo, inclusive no Windows. */
|
|
126
|
+
function openCodeDataDir(): string {
|
|
127
|
+
const xdg = nonEmptyString(process.env.XDG_DATA_HOME)
|
|
128
|
+
return xdg ? join(xdg, "opencode") : join(homedir(), ".local", "share", "opencode")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Lê a chave da API que o próprio OpenCode guardou (ex.: via `/connect`).
|
|
133
|
+
* Somente leitura, sem log; qualquer falha ou formato inesperado retorna undefined.
|
|
134
|
+
*/
|
|
135
|
+
export function readStoredApiKey(integration: string): string | undefined {
|
|
136
|
+
try {
|
|
137
|
+
const raw = readFileSync(join(openCodeDataDir(), "auth.json"), "utf8")
|
|
138
|
+
const parsed = JSON.parse(raw) as Record<string, { type?: unknown; key?: unknown } | undefined>
|
|
139
|
+
const entry = parsed?.[integration]
|
|
140
|
+
return entry ? nonEmptyString(entry.key) : undefined
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ApiKeyResolution {
|
|
147
|
+
key?: string
|
|
148
|
+
source: "env" | "auth-store" | "none"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Env var tem prioridade; senão, o store do OpenCode. */
|
|
152
|
+
export function resolveApiKey(settings: VoiceSettings): ApiKeyResolution {
|
|
153
|
+
const fromEnv = nonEmptyString(process.env[settings.apiKeyEnv])
|
|
154
|
+
if (fromEnv) return { key: fromEnv, source: "env" }
|
|
155
|
+
|
|
156
|
+
if (settings.apiKeyFromAuth) {
|
|
157
|
+
const stored = readStoredApiKey(settings.authIntegration)
|
|
158
|
+
if (stored) return { key: stored, source: "auth-store" }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { source: "none" }
|
|
162
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcrição via OpenRouter (`POST /api/v1/audio/transcriptions`).
|
|
3
|
+
*
|
|
4
|
+
* O endpoint espera JSON com o áudio em base64 (não multipart). Mantemos a
|
|
5
|
+
* interface `TranscriptionProvider` para que outro provedor possa ser adicionado
|
|
6
|
+
* depois sem tocar no resto do plugin.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile } from "node:fs/promises"
|
|
10
|
+
|
|
11
|
+
export interface TranscribeInput {
|
|
12
|
+
/** Caminho do WAV a transcrever. */
|
|
13
|
+
file: string
|
|
14
|
+
apiKey: string
|
|
15
|
+
model: string
|
|
16
|
+
/** ISO-639-1; "auto" ou vazio omite o campo e deixa o provedor detectar. */
|
|
17
|
+
language?: string
|
|
18
|
+
/** Base do provedor; default é o OpenRouter. */
|
|
19
|
+
baseUrl?: string
|
|
20
|
+
timeoutMs?: number
|
|
21
|
+
/** Injetável para testes. */
|
|
22
|
+
fetchImpl?: typeof fetch
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TranscribeOutput {
|
|
26
|
+
text: string
|
|
27
|
+
cost?: number
|
|
28
|
+
seconds?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class TranscribeError extends Error {
|
|
32
|
+
readonly status?: number
|
|
33
|
+
readonly body?: string
|
|
34
|
+
|
|
35
|
+
constructor(message: string, status?: number, body?: string) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.name = "TranscribeError"
|
|
38
|
+
this.status = status
|
|
39
|
+
this.body = body
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function truncate(value: string, max = 500): string {
|
|
44
|
+
return value.length > max ? `${value.slice(0, max)}…` : value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toMessage(error: unknown): string {
|
|
48
|
+
return error instanceof Error ? error.message : String(error)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function transcribe(input: TranscribeInput): Promise<TranscribeOutput> {
|
|
52
|
+
const base = (input.baseUrl ?? "https://openrouter.ai/api/v1").replace(/\/+$/, "")
|
|
53
|
+
const doFetch = input.fetchImpl ?? fetch
|
|
54
|
+
|
|
55
|
+
const audio = await readFile(input.file)
|
|
56
|
+
const body: Record<string, unknown> = {
|
|
57
|
+
model: input.model,
|
|
58
|
+
input_audio: { data: audio.toString("base64"), format: "wav" },
|
|
59
|
+
}
|
|
60
|
+
if (input.language && input.language !== "auto") body.language = input.language
|
|
61
|
+
|
|
62
|
+
const controller = new AbortController()
|
|
63
|
+
const timeout = setTimeout(() => controller.abort(), input.timeoutMs ?? 90_000)
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const response = await doFetch(`${base}/audio/transcriptions`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${input.apiKey}`,
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify(body),
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const raw = await response.text()
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new TranscribeError(`The provider returned ${response.status}.`, response.status, truncate(raw))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let parsed: { text?: unknown; usage?: { cost?: unknown; seconds?: unknown } }
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw) as typeof parsed
|
|
84
|
+
} catch {
|
|
85
|
+
throw new TranscribeError("Invalid response from the provider.", response.status, truncate(raw))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const text = typeof parsed.text === "string" ? parsed.text.trim() : ""
|
|
89
|
+
const cost = typeof parsed.usage?.cost === "number" ? parsed.usage.cost : undefined
|
|
90
|
+
const seconds = typeof parsed.usage?.seconds === "number" ? parsed.usage.seconds : undefined
|
|
91
|
+
return { text, cost, seconds }
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof TranscribeError) throw error
|
|
94
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
95
|
+
throw new TranscribeError("Timed out talking to the provider.")
|
|
96
|
+
}
|
|
97
|
+
throw new TranscribeError(toMessage(error))
|
|
98
|
+
} finally {
|
|
99
|
+
clearTimeout(timeout)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface TranscriptionProvider {
|
|
104
|
+
readonly id: string
|
|
105
|
+
transcribe(input: TranscribeInput): Promise<TranscribeOutput>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const openRouterProvider: TranscriptionProvider = {
|
|
109
|
+
id: "openrouter",
|
|
110
|
+
transcribe,
|
|
111
|
+
}
|