opencode-voice-dictation 0.0.0-stage → 0.1.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,143 @@
1
+ import type { Plugin } from "@opencode/plugin/gui"
2
+ import { Dictation, Speech } from "./contract"
3
+ import { diagnose, startEngine, type EngineHandle } from "./engines/system-speech"
4
+ import { listSapiVoices, speakSapi, type SpeakHandle } from "./engines/sapi-speech"
5
+
6
+ type Status = "idle" | "starting" | "listening" | "error"
7
+
8
+ /**
9
+ * Runs once in the Electron main process.
10
+ *
11
+ * One dictation runs at a time (there is one microphone); the window that
12
+ * starts it receives the transcript events.
13
+ */
14
+ export const main: Plugin.MainSetup = (ctx) => {
15
+ let status: Status = "idle"
16
+ let error = ""
17
+ let locale = ""
18
+ let handle: EngineHandle | undefined
19
+ let stopping = false
20
+
21
+ const reset = () => {
22
+ handle = undefined
23
+ stopping = false
24
+ }
25
+
26
+ const dictation = ctx.provide(Dictation, {
27
+ state: () => ({ status, error, locale }),
28
+
29
+ start(input, caller) {
30
+ if (handle) {
31
+ stopping = true
32
+ handle.stop()
33
+ reset()
34
+ }
35
+
36
+ status = "starting"
37
+ error = ""
38
+ locale = input.locale
39
+ dictation.changed()
40
+
41
+ const target = caller.window
42
+
43
+ try {
44
+ handle = startEngine(
45
+ { locale: input.locale, minConfidence: input.minConfidence },
46
+ {
47
+ onReady(selected) {
48
+ status = "listening"
49
+ locale = selected
50
+ dictation.changed()
51
+ },
52
+ onFinal(text, confidence) {
53
+ dictation.emit("final", { text, confidence }, target)
54
+ },
55
+ onError(code, detail, available) {
56
+ status = "error"
57
+ error = code
58
+ dictation.changed()
59
+ dictation.emit("error", { code, detail, available }, target)
60
+ },
61
+ onExit(code, detail) {
62
+ if (stopping) {
63
+ reset()
64
+ status = "idle"
65
+ error = ""
66
+ locale = ""
67
+ dictation.changed()
68
+ return
69
+ }
70
+
71
+ reset()
72
+ if (status !== "error") {
73
+ status = "error"
74
+ error = code === null ? "ENGINE_EXIT" : `ENGINE_EXIT_${code}`
75
+ dictation.changed()
76
+ dictation.emit(
77
+ "error",
78
+ { code: "ENGINE_EXIT", detail: detail || (code === null ? "" : String(code)), available: [] },
79
+ target,
80
+ )
81
+ }
82
+ },
83
+ },
84
+ )
85
+ } catch (cause) {
86
+ status = "error"
87
+ error = "ENGINE_START_FAILED"
88
+ dictation.changed()
89
+ throw cause
90
+ }
91
+ },
92
+
93
+ stop() {
94
+ if (handle) {
95
+ stopping = true
96
+ handle.stop()
97
+ reset()
98
+ }
99
+ status = "idle"
100
+ error = ""
101
+ locale = ""
102
+ dictation.changed()
103
+ },
104
+
105
+ diagnose() {
106
+ return diagnose()
107
+ },
108
+ })
109
+
110
+ let talking: SpeakHandle | undefined
111
+
112
+ const speech = ctx.provide(Speech, {
113
+ speak(input) {
114
+ talking?.stop()
115
+ talking = speakSapi(input, (ok) => {
116
+ talking = undefined
117
+ speech.emit("done", { ok })
118
+ })
119
+ },
120
+
121
+ stop() {
122
+ if (talking) {
123
+ talking.stop()
124
+ talking = undefined
125
+ }
126
+ speech.emit("done", { ok: true })
127
+ },
128
+
129
+ voices() {
130
+ return listSapiVoices()
131
+ },
132
+ })
133
+
134
+ ctx.onCleanup(() => {
135
+ if (handle) {
136
+ stopping = true
137
+ handle.stop()
138
+ reset()
139
+ }
140
+ talking?.stop()
141
+ talking = undefined
142
+ })
143
+ }
@@ -0,0 +1,244 @@
1
+ import { createSignal, onMount } from "solid-js"
2
+ import { usePlugin } from "@opencode/plugin/gui"
3
+ import { Dictation } from "./contract"
4
+ import type { definition } from "./definition"
5
+ import type { Speaker } from "./speak"
6
+
7
+ const FALLBACK_LOCALES = [
8
+ "es-ES",
9
+ "es-MX",
10
+ "es-AR",
11
+ "es-CO",
12
+ "pt-BR",
13
+ "pt-PT",
14
+ "en-US",
15
+ "en-GB",
16
+ "fr-FR",
17
+ "de-DE",
18
+ "it-IT",
19
+ ]
20
+
21
+ /**
22
+ * Settings page. It only reads and writes the declared store; the engine is
23
+ * asked for installed languages when the page mounts.
24
+ */
25
+ export function VoiceSettings(props: { speaker: Speaker }) {
26
+ const ctx = usePlugin<typeof definition>()
27
+ const ui = ctx.ui
28
+ const prefs = ctx.stores.prefs
29
+ const speechPrefs = ctx.stores.speech
30
+ const dictation = ctx.main(Dictation)
31
+
32
+ const [locales, setLocales] = createSignal<readonly string[]>([])
33
+ const [busy, setBusy] = createSignal(false)
34
+
35
+ onMount(() => {
36
+ void dictation
37
+ .diagnose()
38
+ .then((result) => {
39
+ if (result.locales.length > 0) setLocales(result.locales)
40
+ })
41
+ .catch(() => {})
42
+ void props.speaker.refreshVoices()
43
+ })
44
+
45
+ const localeOptions = () => {
46
+ const list = locales()
47
+ const current = prefs.value.locale
48
+ const merged = list.includes(current) ? list : [current, ...list]
49
+ return merged.map((value) => ({ value, label: value }))
50
+ }
51
+
52
+ const voiceOptions = () => [
53
+ { value: "", label: ctx.t("speech.voice.auto") },
54
+ ...props.speaker.voices().map((voice) => ({ value: voice.id, label: voice.label })),
55
+ ]
56
+
57
+ const patch = (change: { locale?: string; minConfidence?: number; trailingSpace?: boolean; autoFocus?: boolean }) => {
58
+ prefs.update((draft) => {
59
+ if (change.locale !== undefined) draft.locale = change.locale
60
+ if (change.minConfidence !== undefined) draft.minConfidence = change.minConfidence
61
+ if (change.trailingSpace !== undefined) draft.trailingSpace = change.trailingSpace
62
+ if (change.autoFocus !== undefined) draft.autoFocus = change.autoFocus
63
+ })
64
+ }
65
+
66
+ const patchSpeech = (change: { voiceURI?: string; rate?: number; skipCode?: boolean }) => {
67
+ speechPrefs.update((draft) => {
68
+ if (change.voiceURI !== undefined) draft.voiceURI = change.voiceURI
69
+ if (change.rate !== undefined) draft.rate = change.rate
70
+ if (change.skipCode !== undefined) draft.skipCode = change.skipCode
71
+ })
72
+ }
73
+
74
+ const preview = async () => {
75
+ try {
76
+ await props.speaker.preview()
77
+ } catch (cause) {
78
+ ctx.toast.show({
79
+ variant: "error",
80
+ title: ctx.t("read.title"),
81
+ description: cause instanceof Error ? cause.message : ctx.t("read.error"),
82
+ })
83
+ }
84
+ }
85
+
86
+ const test = async () => {
87
+ setBusy(true)
88
+ try {
89
+ const result = await dictation.diagnose()
90
+
91
+ if (result.locales.length === 0) {
92
+ ctx.toast.show({
93
+ variant: "error",
94
+ title: ctx.t("error.title"),
95
+ description: ctx.t("settings.test.failed", { reason: ctx.t("common.reason.no-recognizers") }),
96
+ persistent: true,
97
+ })
98
+ return
99
+ }
100
+
101
+ if (!result.microphone) {
102
+ ctx.toast.show({
103
+ variant: "error",
104
+ title: ctx.t("error.title"),
105
+ description: ctx.t("settings.test.failed", { reason: ctx.t("common.reason.no-microphone") }),
106
+ persistent: true,
107
+ })
108
+ return
109
+ }
110
+
111
+ if (result.locales.length > 0 && !locales().length) setLocales(result.locales)
112
+
113
+ ctx.toast.show({
114
+ variant: "success",
115
+ title: ctx.t("error.title"),
116
+ description: ctx.t("settings.test.success", { locale: result.selected || result.locales[0] || prefs.value.locale }),
117
+ })
118
+ } catch (cause) {
119
+ ctx.toast.show({
120
+ variant: "error",
121
+ title: ctx.t("error.title"),
122
+ description: cause instanceof Error ? cause.message : String(cause),
123
+ })
124
+ } finally {
125
+ setBusy(false)
126
+ }
127
+ }
128
+
129
+ return (
130
+ <ui.SettingsLayout title={ctx.t("settings.title")} description={ctx.t("settings.description")}>
131
+ <ui.SettingsSection title={ctx.t("settings.section")}>
132
+ <ui.SettingsRow
133
+ id="language"
134
+ title={ctx.t("settings.language")}
135
+ description={ctx.t("settings.language.description")}
136
+ >
137
+ <ui.Select
138
+ label={ctx.t("settings.language")}
139
+ value={prefs.value.locale}
140
+ options={localeOptions().length > 0 ? localeOptions() : FALLBACK_LOCALES.map((value) => ({ value, label: value }))}
141
+ onChange={(value) => patch({ locale: value })}
142
+ />
143
+ </ui.SettingsRow>
144
+
145
+ <ui.SettingsRow
146
+ id="confidence"
147
+ title={ctx.t("settings.confidence")}
148
+ description={ctx.t("settings.confidence.description")}
149
+ >
150
+ <ui.Select
151
+ label={ctx.t("settings.confidence")}
152
+ value={String(prefs.value.minConfidence)}
153
+ options={[
154
+ { value: "0.2", label: ctx.t("confidence.low") },
155
+ { value: "0.4", label: ctx.t("confidence.normal") },
156
+ { value: "0.6", label: ctx.t("confidence.high") },
157
+ ]}
158
+ onChange={(value) => patch({ minConfidence: Number(value) || 0.4 })}
159
+ />
160
+ </ui.SettingsRow>
161
+
162
+ <ui.SettingsRow id="space" title={ctx.t("settings.space")} description={ctx.t("settings.space.description")}>
163
+ <ui.Switch
164
+ label={ctx.t("settings.space")}
165
+ hideLabel
166
+ checked={prefs.value.trailingSpace}
167
+ onChange={(checked) => patch({ trailingSpace: checked })}
168
+ />
169
+ </ui.SettingsRow>
170
+
171
+ <ui.SettingsRow id="focus" title={ctx.t("settings.focus")} description={ctx.t("settings.focus.description")}>
172
+ <ui.Switch
173
+ label={ctx.t("settings.focus")}
174
+ hideLabel
175
+ checked={prefs.value.autoFocus}
176
+ onChange={(checked) => patch({ autoFocus: checked })}
177
+ />
178
+ </ui.SettingsRow>
179
+ </ui.SettingsSection>
180
+
181
+ <ui.SettingsSection title={ctx.t("speech.section")}>
182
+ <ui.SettingsRow
183
+ id="voice"
184
+ title={ctx.t("speech.voice")}
185
+ description={ctx.t("speech.voice.description")}
186
+ >
187
+ <ui.Select
188
+ label={ctx.t("speech.voice")}
189
+ value={speechPrefs.value.voiceURI}
190
+ options={voiceOptions()}
191
+ onChange={(value) => patchSpeech({ voiceURI: value })}
192
+ />
193
+ </ui.SettingsRow>
194
+
195
+ <ui.SettingsRow
196
+ id="rate"
197
+ title={ctx.t("speech.rate")}
198
+ description={ctx.t("speech.rate.description")}
199
+ >
200
+ <ui.Select
201
+ label={ctx.t("speech.rate")}
202
+ value={String(speechPrefs.value.rate)}
203
+ options={[
204
+ { value: "0.8", label: ctx.t("speech.rate.slow") },
205
+ { value: "1", label: ctx.t("speech.rate.normal") },
206
+ { value: "1.2", label: ctx.t("speech.rate.fast") },
207
+ { value: "1.5", label: ctx.t("speech.rate.faster") },
208
+ ]}
209
+ onChange={(value) => patchSpeech({ rate: Number(value) || 1 })}
210
+ />
211
+ </ui.SettingsRow>
212
+
213
+ <ui.SettingsRow
214
+ id="skip-code"
215
+ title={ctx.t("speech.skipCode")}
216
+ description={ctx.t("speech.skipCode.description")}
217
+ >
218
+ <ui.Switch
219
+ label={ctx.t("speech.skipCode")}
220
+ hideLabel
221
+ checked={speechPrefs.value.skipCode}
222
+ onChange={(checked) => patchSpeech({ skipCode: checked })}
223
+ />
224
+ </ui.SettingsRow>
225
+
226
+ <ui.SettingsRow
227
+ id="preview"
228
+ title={ctx.t("speech.preview")}
229
+ description={ctx.t("speech.preview.description")}
230
+ >
231
+ <ui.Button onClick={() => void preview()}>{ctx.t("read.idle")}</ui.Button>
232
+ </ui.SettingsRow>
233
+ </ui.SettingsSection>
234
+
235
+ <ui.SettingsSection title={ctx.t("settings.test.section")}>
236
+ <ui.SettingsRow id="test" title={ctx.t("settings.test")} description={ctx.t("settings.test.description")}>
237
+ <ui.Button busy={busy()} onClick={() => void test()}>
238
+ {ctx.t("settings.test.run")}
239
+ </ui.Button>
240
+ </ui.SettingsRow>
241
+ </ui.SettingsSection>
242
+ </ui.SettingsLayout>
243
+ )
244
+ }