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.
- package/LICENSE +21 -0
- package/README.md +252 -2
- package/dist/gui-experimental.cjs +165 -0
- package/package.json +53 -4
- package/src/gui/contract.ts +77 -0
- package/src/gui/definition.ts +175 -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/main.ts +143 -0
- package/src/gui/settings.tsx +244 -0
- package/src/gui/speak.ts +260 -0
- package/src/gui/window.tsx +239 -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
|
+
}
|
package/src/gui/main.ts
ADDED
|
@@ -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
|
+
}
|