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.
@@ -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
+ }
@@ -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
+ }