opencode-voice-dictation 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +272 -2
- package/dist/gui-experimental.cjs +192 -0
- package/package.json +53 -4
- package/src/gui/contract.ts +87 -0
- package/src/gui/definition.ts +204 -0
- package/src/gui/engines/sapi-speech.ts +151 -0
- package/src/gui/engines/script.ts +125 -0
- package/src/gui/engines/speak-script.ts +66 -0
- package/src/gui/engines/system-speech.ts +202 -0
- package/src/gui/engines/win-h.ts +80 -0
- package/src/gui/main.ts +150 -0
- package/src/gui/settings.tsx +297 -0
- package/src/gui/speak.ts +260 -0
- package/src/gui/window.tsx +289 -0
- package/src/gui.tsx +6 -0
- package/src/index.ts +19 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import type { DiagnoseResult } from "../contract"
|
|
7
|
+
import { DICTATION_SCRIPT } from "./script"
|
|
8
|
+
|
|
9
|
+
export interface EngineOptions {
|
|
10
|
+
locale: string
|
|
11
|
+
minConfidence: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EngineCallbacks {
|
|
15
|
+
onReady(locale: string): void
|
|
16
|
+
onFinal(text: string, confidence: number): void
|
|
17
|
+
onError(code: string, detail: string, available: string[]): void
|
|
18
|
+
onExit(code: number | null, detail: string): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EngineHandle {
|
|
22
|
+
stop(): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface EngineEvent {
|
|
26
|
+
type?: string
|
|
27
|
+
locale?: string
|
|
28
|
+
text?: string
|
|
29
|
+
confidence?: number
|
|
30
|
+
code?: string
|
|
31
|
+
detail?: string
|
|
32
|
+
available?: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const POWERSHELL = "powershell.exe"
|
|
36
|
+
|
|
37
|
+
function scriptFile(): string {
|
|
38
|
+
const dir = join(tmpdir(), "opencode-voice-dictation")
|
|
39
|
+
mkdirSync(dir, { recursive: true })
|
|
40
|
+
const hash = createHash("sha1").update(DICTATION_SCRIPT).digest("hex").slice(0, 12)
|
|
41
|
+
const file = join(dir, `dictation-${hash}.ps1`)
|
|
42
|
+
writeFileSync(file, DICTATION_SCRIPT, "utf8")
|
|
43
|
+
return file
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function spawnPowershell(args: string[]): ChildProcess {
|
|
47
|
+
return spawn(POWERSHELL, args, {
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function killTree(child: ChildProcess) {
|
|
54
|
+
const pid = child.pid
|
|
55
|
+
if (pid === undefined) {
|
|
56
|
+
child.kill()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
stdio: "ignore",
|
|
62
|
+
})
|
|
63
|
+
killer.on("error", () => child.kill())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function startEngine(options: EngineOptions, callbacks: EngineCallbacks): EngineHandle {
|
|
67
|
+
const child = spawnPowershell([
|
|
68
|
+
"-NoProfile",
|
|
69
|
+
"-NonInteractive",
|
|
70
|
+
"-ExecutionPolicy",
|
|
71
|
+
"Bypass",
|
|
72
|
+
"-File",
|
|
73
|
+
scriptFile(),
|
|
74
|
+
"-Culture",
|
|
75
|
+
options.locale,
|
|
76
|
+
"-MinConfidence",
|
|
77
|
+
String(options.minConfidence),
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
let stopped = false
|
|
81
|
+
let stderr = ""
|
|
82
|
+
let buffer = ""
|
|
83
|
+
|
|
84
|
+
const emit = (event: EngineEvent) => {
|
|
85
|
+
switch (event.type) {
|
|
86
|
+
case "ready":
|
|
87
|
+
callbacks.onReady(event.locale ?? options.locale)
|
|
88
|
+
break
|
|
89
|
+
case "final":
|
|
90
|
+
if (event.text) callbacks.onFinal(event.text, event.confidence ?? 0)
|
|
91
|
+
break
|
|
92
|
+
case "error":
|
|
93
|
+
callbacks.onError(event.code ?? "UNKNOWN", event.detail ?? "", event.available ?? [])
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
child.stdout?.setEncoding("utf8")
|
|
99
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
100
|
+
buffer += chunk
|
|
101
|
+
let index = buffer.indexOf("\n")
|
|
102
|
+
while (index >= 0) {
|
|
103
|
+
const line = buffer.slice(0, index).replace(/\r$/, "").trim()
|
|
104
|
+
buffer = buffer.slice(index + 1)
|
|
105
|
+
if (line) {
|
|
106
|
+
try {
|
|
107
|
+
emit(JSON.parse(line) as EngineEvent)
|
|
108
|
+
} catch {
|
|
109
|
+
// A partial or unexpected line: ignore it and keep the stream alive.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
index = buffer.indexOf("\n")
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
child.stderr?.setEncoding("utf8")
|
|
117
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
118
|
+
stderr = (stderr + chunk).slice(-2000)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
child.on("error", (cause) => {
|
|
122
|
+
if (stopped) return
|
|
123
|
+
callbacks.onError("ENGINE_START_FAILED", cause.message, [])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
child.on("close", (code) => {
|
|
127
|
+
if (stopped) return
|
|
128
|
+
callbacks.onExit(code, stderr.trim().slice(-500))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
stop() {
|
|
133
|
+
stopped = true
|
|
134
|
+
killTree(child)
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function diagnose(): Promise<DiagnoseResult> {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const child = spawnPowershell([
|
|
142
|
+
"-NoProfile",
|
|
143
|
+
"-NonInteractive",
|
|
144
|
+
"-ExecutionPolicy",
|
|
145
|
+
"Bypass",
|
|
146
|
+
"-File",
|
|
147
|
+
scriptFile(),
|
|
148
|
+
"-Diagnose",
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
let stdout = ""
|
|
152
|
+
let stderr = ""
|
|
153
|
+
let settled = false
|
|
154
|
+
|
|
155
|
+
const finish = (error: Error | undefined, value?: DiagnoseResult) => {
|
|
156
|
+
if (settled) return
|
|
157
|
+
settled = true
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
if (error) reject(error)
|
|
160
|
+
else resolve(value as DiagnoseResult)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
killTree(child)
|
|
165
|
+
finish(new Error("diagnose timed out"))
|
|
166
|
+
}, 20000)
|
|
167
|
+
|
|
168
|
+
child.stdout?.setEncoding("utf8")
|
|
169
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
170
|
+
stdout += chunk
|
|
171
|
+
})
|
|
172
|
+
child.stderr?.setEncoding("utf8")
|
|
173
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
174
|
+
stderr += chunk
|
|
175
|
+
})
|
|
176
|
+
child.on("error", (cause) => finish(cause))
|
|
177
|
+
child.on("close", () => {
|
|
178
|
+
const line = stdout
|
|
179
|
+
.split(/\r?\n/)
|
|
180
|
+
.map((entry) => entry.trim())
|
|
181
|
+
.filter((entry) => entry.startsWith("{"))
|
|
182
|
+
.at(-1)
|
|
183
|
+
|
|
184
|
+
if (!line) {
|
|
185
|
+
finish(new Error(stderr.trim() || "no output from PowerShell"))
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(line) as DiagnoseResult
|
|
191
|
+
finish(undefined, {
|
|
192
|
+
locales: parsed.locales ?? [],
|
|
193
|
+
microphone: parsed.microphone === true,
|
|
194
|
+
selected: parsed.selected ?? "",
|
|
195
|
+
error: parsed.error ?? "",
|
|
196
|
+
})
|
|
197
|
+
} catch (cause) {
|
|
198
|
+
finish(cause instanceof Error ? cause : new Error(String(cause)))
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sends the Windows voice typing shortcut (Win+H).
|
|
9
|
+
*
|
|
10
|
+
* `SendKeys` cannot press the Windows key, so this uses `keybd_event` from
|
|
11
|
+
* user32.dll through an inline C# type compiled by `Add-Type`.
|
|
12
|
+
*
|
|
13
|
+
* Keep the script ASCII-only: Windows PowerShell 5.1 reads a BOM-less script
|
|
14
|
+
* as ANSI.
|
|
15
|
+
*/
|
|
16
|
+
export const WIN_H_SCRIPT = String.raw`$ErrorActionPreference = "Stop"
|
|
17
|
+
|
|
18
|
+
Add-Type @"
|
|
19
|
+
using System;
|
|
20
|
+
using System.Runtime.InteropServices;
|
|
21
|
+
|
|
22
|
+
public static class WinH {
|
|
23
|
+
[DllImport("user32.dll", SetLastError = true)]
|
|
24
|
+
public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
|
|
25
|
+
|
|
26
|
+
private const byte VK_LWIN = 0x5B;
|
|
27
|
+
private const byte VK_H = 0x48;
|
|
28
|
+
private const uint KEYUP = 0x0002;
|
|
29
|
+
|
|
30
|
+
public static void Send() {
|
|
31
|
+
keybd_event(VK_LWIN, 0, 0, UIntPtr.Zero);
|
|
32
|
+
System.Threading.Thread.Sleep(60);
|
|
33
|
+
keybd_event(VK_H, 0, 0, UIntPtr.Zero);
|
|
34
|
+
System.Threading.Thread.Sleep(60);
|
|
35
|
+
keybd_event(VK_H, 0, KEYUP, UIntPtr.Zero);
|
|
36
|
+
System.Threading.Thread.Sleep(60);
|
|
37
|
+
keybd_event(VK_LWIN, 0, KEYUP, UIntPtr.Zero);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
"@
|
|
41
|
+
|
|
42
|
+
[WinH]::Send()
|
|
43
|
+
`
|
|
44
|
+
|
|
45
|
+
const POWERSHELL = "powershell.exe"
|
|
46
|
+
|
|
47
|
+
function scriptFile(): string {
|
|
48
|
+
const dir = join(tmpdir(), "opencode-voice-dictation")
|
|
49
|
+
mkdirSync(dir, { recursive: true })
|
|
50
|
+
const hash = createHash("sha1").update(WIN_H_SCRIPT).digest("hex").slice(0, 12)
|
|
51
|
+
const file = join(dir, `win-h-${hash}.ps1`)
|
|
52
|
+
writeFileSync(file, WIN_H_SCRIPT, "utf8")
|
|
53
|
+
return file
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function spawnPowershell(args: string[]): ChildProcess {
|
|
57
|
+
return spawn(POWERSHELL, args, {
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
stdio: "ignore",
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function sendVoiceTypingShortcut(): Promise<void> {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const child = spawnPowershell([
|
|
66
|
+
"-NoProfile",
|
|
67
|
+
"-NonInteractive",
|
|
68
|
+
"-ExecutionPolicy",
|
|
69
|
+
"Bypass",
|
|
70
|
+
"-File",
|
|
71
|
+
scriptFile(),
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
child.on("error", (cause) => reject(cause))
|
|
75
|
+
child.on("close", (code) => {
|
|
76
|
+
if (code === 0) resolve()
|
|
77
|
+
else reject(new Error(`Win+H helper exited with code ${code ?? "unknown"}`))
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
}
|
package/src/gui/main.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/gui"
|
|
2
|
+
import { Dictation, Speech, VoiceTyping } from "./contract"
|
|
3
|
+
import { diagnose, startEngine, type EngineHandle } from "./engines/system-speech"
|
|
4
|
+
import { listSapiVoices, speakSapi, type SpeakHandle } from "./engines/sapi-speech"
|
|
5
|
+
import { sendVoiceTypingShortcut } from "./engines/win-h"
|
|
6
|
+
|
|
7
|
+
type Status = "idle" | "starting" | "listening" | "error"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs once in the Electron main process.
|
|
11
|
+
*
|
|
12
|
+
* One dictation runs at a time (there is one microphone); the window that
|
|
13
|
+
* starts it receives the transcript events.
|
|
14
|
+
*/
|
|
15
|
+
export const main: Plugin.MainSetup = (ctx) => {
|
|
16
|
+
let status: Status = "idle"
|
|
17
|
+
let error = ""
|
|
18
|
+
let locale = ""
|
|
19
|
+
let handle: EngineHandle | undefined
|
|
20
|
+
let stopping = false
|
|
21
|
+
|
|
22
|
+
const reset = () => {
|
|
23
|
+
handle = undefined
|
|
24
|
+
stopping = false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const dictation = ctx.provide(Dictation, {
|
|
28
|
+
state: () => ({ status, error, locale }),
|
|
29
|
+
|
|
30
|
+
start(input, caller) {
|
|
31
|
+
if (handle) {
|
|
32
|
+
stopping = true
|
|
33
|
+
handle.stop()
|
|
34
|
+
reset()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
status = "starting"
|
|
38
|
+
error = ""
|
|
39
|
+
locale = input.locale
|
|
40
|
+
dictation.changed()
|
|
41
|
+
|
|
42
|
+
const target = caller.window
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
handle = startEngine(
|
|
46
|
+
{ locale: input.locale, minConfidence: input.minConfidence },
|
|
47
|
+
{
|
|
48
|
+
onReady(selected) {
|
|
49
|
+
status = "listening"
|
|
50
|
+
locale = selected
|
|
51
|
+
dictation.changed()
|
|
52
|
+
},
|
|
53
|
+
onFinal(text, confidence) {
|
|
54
|
+
dictation.emit("final", { text, confidence }, target)
|
|
55
|
+
},
|
|
56
|
+
onError(code, detail, available) {
|
|
57
|
+
status = "error"
|
|
58
|
+
error = code
|
|
59
|
+
dictation.changed()
|
|
60
|
+
dictation.emit("error", { code, detail, available }, target)
|
|
61
|
+
},
|
|
62
|
+
onExit(code, detail) {
|
|
63
|
+
if (stopping) {
|
|
64
|
+
reset()
|
|
65
|
+
status = "idle"
|
|
66
|
+
error = ""
|
|
67
|
+
locale = ""
|
|
68
|
+
dictation.changed()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
reset()
|
|
73
|
+
if (status !== "error") {
|
|
74
|
+
status = "error"
|
|
75
|
+
error = code === null ? "ENGINE_EXIT" : `ENGINE_EXIT_${code}`
|
|
76
|
+
dictation.changed()
|
|
77
|
+
dictation.emit(
|
|
78
|
+
"error",
|
|
79
|
+
{ code: "ENGINE_EXIT", detail: detail || (code === null ? "" : String(code)), available: [] },
|
|
80
|
+
target,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
status = "error"
|
|
88
|
+
error = "ENGINE_START_FAILED"
|
|
89
|
+
dictation.changed()
|
|
90
|
+
throw cause
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
stop() {
|
|
95
|
+
if (handle) {
|
|
96
|
+
stopping = true
|
|
97
|
+
handle.stop()
|
|
98
|
+
reset()
|
|
99
|
+
}
|
|
100
|
+
status = "idle"
|
|
101
|
+
error = ""
|
|
102
|
+
locale = ""
|
|
103
|
+
dictation.changed()
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
diagnose() {
|
|
107
|
+
return diagnose()
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
let talking: SpeakHandle | undefined
|
|
112
|
+
|
|
113
|
+
const speech = ctx.provide(Speech, {
|
|
114
|
+
speak(input) {
|
|
115
|
+
talking?.stop()
|
|
116
|
+
talking = speakSapi(input, (ok) => {
|
|
117
|
+
talking = undefined
|
|
118
|
+
speech.emit("done", { ok })
|
|
119
|
+
})
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
stop() {
|
|
123
|
+
if (talking) {
|
|
124
|
+
talking.stop()
|
|
125
|
+
talking = undefined
|
|
126
|
+
}
|
|
127
|
+
speech.emit("done", { ok: true })
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
voices() {
|
|
131
|
+
return listSapiVoices()
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
ctx.provide(VoiceTyping, {
|
|
136
|
+
toggle() {
|
|
137
|
+
return sendVoiceTypingShortcut()
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
ctx.onCleanup(() => {
|
|
142
|
+
if (handle) {
|
|
143
|
+
stopping = true
|
|
144
|
+
handle.stop()
|
|
145
|
+
reset()
|
|
146
|
+
}
|
|
147
|
+
talking?.stop()
|
|
148
|
+
talking = undefined
|
|
149
|
+
})
|
|
150
|
+
}
|