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
package/src/gui/speak.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createSignal } from "solid-js"
|
|
2
|
+
import type { IpcClient, SetupContext } from "@opencode/plugin/gui"
|
|
3
|
+
import { Speech } from "./contract"
|
|
4
|
+
import type { definition } from "./definition"
|
|
5
|
+
|
|
6
|
+
export interface VoiceOption {
|
|
7
|
+
id: string
|
|
8
|
+
label: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Speaker {
|
|
12
|
+
speaking(): boolean
|
|
13
|
+
backend(): "web" | "sapi" | "none"
|
|
14
|
+
voices(): VoiceOption[]
|
|
15
|
+
refreshVoices(): Promise<void>
|
|
16
|
+
lastAssistantText(): Promise<string>
|
|
17
|
+
speak(text: string): Promise<boolean>
|
|
18
|
+
cancel(): void
|
|
19
|
+
preview(): Promise<boolean>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_CHUNK = 250
|
|
23
|
+
|
|
24
|
+
/** Turns Markdown into something worth hearing. */
|
|
25
|
+
export function cleanForSpeech(text: string, skipCode: boolean): string {
|
|
26
|
+
let value = text.replace(/\r\n/g, "\n")
|
|
27
|
+
|
|
28
|
+
if (skipCode) {
|
|
29
|
+
value = value.replace(/```[\s\S]*?```/g, " ")
|
|
30
|
+
} else {
|
|
31
|
+
value = value.replace(/```[^\n]*\n([\s\S]*?)```/g, "$1")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
value = value.replace(/`([^`]+)`/g, "$1")
|
|
35
|
+
value = value.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
|
36
|
+
value = value.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
37
|
+
value = value.replace(/^\s*#{1,6}\s+/gm, "")
|
|
38
|
+
value = value.replace(/^\s*>\s?/gm, "")
|
|
39
|
+
value = value.replace(/^\s*[-*+]\s+/gm, "")
|
|
40
|
+
value = value.replace(/^\s*\d+\.\s+/gm, "")
|
|
41
|
+
value = value.replace(/(\*\*|__)(.*?)\1/g, "$2")
|
|
42
|
+
value = value.replace(/(\*|_)(.*?)\1/g, "$2")
|
|
43
|
+
value = value.replace(/^\s*\|.*\|\s*$/gm, " ")
|
|
44
|
+
value = value.replace(/\n{2,}/g, ". ")
|
|
45
|
+
value = value.replace(/\n/g, " ")
|
|
46
|
+
value = value.replace(/\s+/g, " ")
|
|
47
|
+
value = value.replace(/\s+([.,;:!?])/g, "$1")
|
|
48
|
+
value = value.replace(/([.,;:!?]){2,}/g, "$1")
|
|
49
|
+
|
|
50
|
+
return value.trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Splits at sentence boundaries, then at word boundaries for long sentences. */
|
|
54
|
+
export function chunkForSpeech(text: string, max = MAX_CHUNK): string[] {
|
|
55
|
+
const trimmed = text.trim()
|
|
56
|
+
if (!trimmed) return []
|
|
57
|
+
|
|
58
|
+
const sentences = trimmed.split(/(?<=[.!?…])\s+/)
|
|
59
|
+
const chunks: string[] = []
|
|
60
|
+
let current = ""
|
|
61
|
+
|
|
62
|
+
const pushWords = (sentence: string) => {
|
|
63
|
+
let part = ""
|
|
64
|
+
for (const word of sentence.split(/\s+/)) {
|
|
65
|
+
if (!word) continue
|
|
66
|
+
if (part && part.length + 1 + word.length > max) {
|
|
67
|
+
chunks.push(part)
|
|
68
|
+
part = word
|
|
69
|
+
} else {
|
|
70
|
+
part = part ? `${part} ${word}` : word
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return part
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const sentence of sentences) {
|
|
77
|
+
if (sentence.length > max) {
|
|
78
|
+
if (current) {
|
|
79
|
+
chunks.push(current)
|
|
80
|
+
current = ""
|
|
81
|
+
}
|
|
82
|
+
current = pushWords(sentence)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (current && current.length + 1 + sentence.length > max) {
|
|
87
|
+
chunks.push(current)
|
|
88
|
+
current = sentence
|
|
89
|
+
} else {
|
|
90
|
+
current = current ? `${current} ${sentence}` : sentence
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (current) chunks.push(current)
|
|
95
|
+
return chunks
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createSpeaker(
|
|
99
|
+
ctx: SetupContext<typeof definition>,
|
|
100
|
+
speech: IpcClient<typeof Speech.spec>,
|
|
101
|
+
): Speaker {
|
|
102
|
+
const prefs = ctx.stores.speech
|
|
103
|
+
|
|
104
|
+
const [speaking, setSpeaking] = createSignal(false)
|
|
105
|
+
const [voices, setVoices] = createSignal<VoiceOption[]>([])
|
|
106
|
+
const [backend, setBackend] = createSignal<"web" | "sapi" | "none">("none")
|
|
107
|
+
|
|
108
|
+
let generation = 0
|
|
109
|
+
let queue: string[] = []
|
|
110
|
+
|
|
111
|
+
const synth = () =>
|
|
112
|
+
typeof globalThis.speechSynthesis !== "undefined" ? globalThis.speechSynthesis : undefined
|
|
113
|
+
|
|
114
|
+
const pickVoice = () => {
|
|
115
|
+
const available = synth()?.getVoices() ?? []
|
|
116
|
+
if (available.length === 0) return undefined
|
|
117
|
+
|
|
118
|
+
const wanted = prefs.value.voiceURI
|
|
119
|
+
if (wanted) {
|
|
120
|
+
const chosen = available.find((voice) => voice.voiceURI === wanted)
|
|
121
|
+
if (chosen) return chosen
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const base = ctx.locale.locale().split("-")[0]?.toLowerCase() ?? "es"
|
|
125
|
+
return available.find((voice) => voice.lang.toLowerCase().startsWith(base)) ?? available[0]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const refreshVoices = async () => {
|
|
129
|
+
const available = synth()?.getVoices() ?? []
|
|
130
|
+
|
|
131
|
+
if (available.length > 0) {
|
|
132
|
+
setVoices(
|
|
133
|
+
available.map((voice) => ({
|
|
134
|
+
id: voice.voiceURI,
|
|
135
|
+
label: `${voice.name} — ${voice.lang}`,
|
|
136
|
+
})),
|
|
137
|
+
)
|
|
138
|
+
setBackend("web")
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const names = await speech.voices()
|
|
144
|
+
if (names.length > 0) {
|
|
145
|
+
setVoices(names.map((name) => ({ id: name, label: name })))
|
|
146
|
+
setBackend("sapi")
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Main is unreachable or PowerShell failed; treat as no voices.
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setBackend("none")
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cancel = () => {
|
|
157
|
+
generation++
|
|
158
|
+
queue = []
|
|
159
|
+
synth()?.cancel()
|
|
160
|
+
setSpeaking(false)
|
|
161
|
+
void speech.stop().catch(() => {})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const speak = async (text: string) => {
|
|
165
|
+
const cleaned = cleanForSpeech(text, prefs.value.skipCode)
|
|
166
|
+
if (!cleaned) return false
|
|
167
|
+
|
|
168
|
+
cancel()
|
|
169
|
+
await refreshVoices()
|
|
170
|
+
|
|
171
|
+
if (backend() === "web") {
|
|
172
|
+
const engine = synth()
|
|
173
|
+
if (!engine) return false
|
|
174
|
+
|
|
175
|
+
const voice = pickVoice()
|
|
176
|
+
const rate = prefs.value.rate
|
|
177
|
+
const gen = generation
|
|
178
|
+
queue = chunkForSpeech(cleaned)
|
|
179
|
+
setSpeaking(true)
|
|
180
|
+
|
|
181
|
+
const pump = () => {
|
|
182
|
+
if (gen !== generation) return
|
|
183
|
+
const next = queue.shift()
|
|
184
|
+
if (!next) {
|
|
185
|
+
setSpeaking(false)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const utterance = new SpeechSynthesisUtterance(next)
|
|
190
|
+
if (voice) utterance.voice = voice
|
|
191
|
+
if (voice?.lang) utterance.lang = voice.lang
|
|
192
|
+
utterance.rate = rate
|
|
193
|
+
utterance.onend = pump
|
|
194
|
+
utterance.onerror = pump
|
|
195
|
+
engine.speak(utterance)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
pump()
|
|
199
|
+
return true
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (backend() === "sapi") {
|
|
203
|
+
setSpeaking(true)
|
|
204
|
+
try {
|
|
205
|
+
await speech.speak({
|
|
206
|
+
text: cleaned,
|
|
207
|
+
rate: prefs.value.rate,
|
|
208
|
+
voice: prefs.value.voiceURI,
|
|
209
|
+
})
|
|
210
|
+
} catch (cause) {
|
|
211
|
+
setSpeaking(false)
|
|
212
|
+
throw cause
|
|
213
|
+
}
|
|
214
|
+
return true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
setSpeaking(false)
|
|
218
|
+
throw new Error(ctx.t("read.noVoices"))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const lastAssistantText = async () => {
|
|
222
|
+
const session = ctx.sessions.current()
|
|
223
|
+
if (!session) return ""
|
|
224
|
+
|
|
225
|
+
// The session may live on another server than the one that runs the plugin
|
|
226
|
+
// (the desktop app can show WSL and Windows servers side by side), so ask
|
|
227
|
+
// the session's own server instead of `ctx.client`.
|
|
228
|
+
const page = await session.server.client.message.list({
|
|
229
|
+
sessionID: session.id,
|
|
230
|
+
type: "assistant",
|
|
231
|
+
order: "desc",
|
|
232
|
+
limit: 1,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const message = page.data[0]
|
|
236
|
+
if (!message || message.type !== "assistant") return ""
|
|
237
|
+
|
|
238
|
+
return message.content
|
|
239
|
+
.filter((part) => part.type === "text")
|
|
240
|
+
.map((part) => part.text)
|
|
241
|
+
.join("\n")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
speech.on("done", () => setSpeaking(false))
|
|
245
|
+
|
|
246
|
+
const engine = synth()
|
|
247
|
+
engine?.addEventListener?.("voiceschanged", () => void refreshVoices())
|
|
248
|
+
void refreshVoices()
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
speaking,
|
|
252
|
+
backend,
|
|
253
|
+
voices,
|
|
254
|
+
refreshVoices,
|
|
255
|
+
lastAssistantText,
|
|
256
|
+
speak,
|
|
257
|
+
cancel,
|
|
258
|
+
preview: () => speak(ctx.t("speech.preview.text")),
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { Command, ComposerPill, SettingsPage, type ComposerPillView, type Plugin } from "@opencode/plugin/gui"
|
|
2
|
+
import { Dictation, Speech } from "./contract"
|
|
3
|
+
import type { definition } from "./definition"
|
|
4
|
+
import { createSpeaker } from "./speak"
|
|
5
|
+
import { VoiceSettings } from "./settings"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Window setup: the microphone pill above the composer, the keybind and the
|
|
9
|
+
* `/dictado` slash command.
|
|
10
|
+
*/
|
|
11
|
+
export const setup: Plugin.Setup<typeof definition> = (ctx) => {
|
|
12
|
+
const dictation = ctx.main(Dictation)
|
|
13
|
+
const speech = ctx.main(Speech)
|
|
14
|
+
const speaker = createSpeaker(ctx, speech)
|
|
15
|
+
const prefs = ctx.stores.prefs
|
|
16
|
+
|
|
17
|
+
let targetSession: string | undefined
|
|
18
|
+
|
|
19
|
+
const reason = (code: string, detail: string, available: readonly string[]) => {
|
|
20
|
+
switch (code) {
|
|
21
|
+
case "LANGUAGE_NOT_AVAILABLE":
|
|
22
|
+
return ctx.t("error.language", {
|
|
23
|
+
locale: detail || prefs.value.locale,
|
|
24
|
+
available: available.length > 0 ? available.join(", ") : "—",
|
|
25
|
+
})
|
|
26
|
+
case "NO_RECOGNIZERS":
|
|
27
|
+
return ctx.t("error.none")
|
|
28
|
+
case "MICROPHONE_UNAVAILABLE":
|
|
29
|
+
return ctx.t("error.mic")
|
|
30
|
+
case "SYSTEM_SPEECH_UNAVAILABLE":
|
|
31
|
+
return ctx.t("error.system")
|
|
32
|
+
case "ENGINE_EXIT":
|
|
33
|
+
return ctx.t("error.exit")
|
|
34
|
+
default:
|
|
35
|
+
return ctx.t("error.unknown", { code })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const start = () => {
|
|
40
|
+
speaker.cancel()
|
|
41
|
+
targetSession = ctx.sessions.current()?.key
|
|
42
|
+
void dictation
|
|
43
|
+
.start({ locale: prefs.value.locale, minConfidence: prefs.value.minConfidence })
|
|
44
|
+
.catch((cause: unknown) => {
|
|
45
|
+
ctx.toast.show({
|
|
46
|
+
variant: "error",
|
|
47
|
+
title: ctx.t("error.title"),
|
|
48
|
+
description: cause instanceof Error ? cause.message : String(cause),
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const stop = () => {
|
|
54
|
+
void dictation.stop().catch(() => {})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const toggle = () => {
|
|
58
|
+
const status = dictation.state()?.status ?? "idle"
|
|
59
|
+
if (status === "starting" || status === "listening") stop()
|
|
60
|
+
else start()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const read = () => {
|
|
64
|
+
if (speaker.speaking()) {
|
|
65
|
+
speaker.cancel()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!ctx.sessions.current()) {
|
|
70
|
+
ctx.toast.show({ variant: "error", title: ctx.t("read.title"), description: ctx.t("read.noSession") })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const status = dictation.state()?.status ?? "idle"
|
|
75
|
+
if (status === "starting" || status === "listening") void dictation.stop().catch(() => {})
|
|
76
|
+
|
|
77
|
+
void (async () => {
|
|
78
|
+
try {
|
|
79
|
+
const text = await speaker.lastAssistantText()
|
|
80
|
+
if (!text.trim()) {
|
|
81
|
+
ctx.toast.show({ variant: "default", title: ctx.t("read.title"), description: ctx.t("read.empty") })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const started = await speaker.speak(text)
|
|
85
|
+
if (!started) {
|
|
86
|
+
ctx.toast.show({ variant: "default", title: ctx.t("read.title"), description: ctx.t("read.empty") })
|
|
87
|
+
}
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
ctx.toast.show({
|
|
90
|
+
variant: "error",
|
|
91
|
+
title: ctx.t("read.title"),
|
|
92
|
+
description: cause instanceof Error ? cause.message : ctx.t("read.error"),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
})()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dictation.on("final", (data) => {
|
|
99
|
+
const current = ctx.sessions.current()?.key
|
|
100
|
+
|
|
101
|
+
if (targetSession && current && current !== targetSession) {
|
|
102
|
+
ctx.toast.show({ variant: "error", title: ctx.t("error.title"), description: ctx.t("toast.discarded") })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const composer = ctx.screen.current()?.composer
|
|
107
|
+
if (!composer) return
|
|
108
|
+
|
|
109
|
+
const existing = composer.text()
|
|
110
|
+
const separator = prefs.value.trailingSpace && existing.length > 0 && !/\s$/.test(existing) ? " " : ""
|
|
111
|
+
composer.insert(separator + data.text)
|
|
112
|
+
|
|
113
|
+
if (prefs.value.autoFocus) composer.focus()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
dictation.on("error", (data) => {
|
|
117
|
+
ctx.toast.show({
|
|
118
|
+
variant: "error",
|
|
119
|
+
title: ctx.t("error.title"),
|
|
120
|
+
description: reason(data.code, data.detail, data.available),
|
|
121
|
+
persistent: data.code === "LANGUAGE_NOT_AVAILABLE" || data.code === "NO_RECOGNIZERS",
|
|
122
|
+
duration: 8000,
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const view = (): ComposerPillView | undefined => {
|
|
127
|
+
if (ctx.app.platform !== "desktop") return undefined
|
|
128
|
+
|
|
129
|
+
const status = dictation.state()?.status ?? "idle"
|
|
130
|
+
|
|
131
|
+
if (status === "starting") {
|
|
132
|
+
return { label: ctx.t("pill.starting"), icon: "outline-wave-audio", title: ctx.t("pill.title"), run: toggle }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (status === "listening") {
|
|
136
|
+
return {
|
|
137
|
+
label: ctx.t("pill.listening"),
|
|
138
|
+
icon: "outline-wave-audio",
|
|
139
|
+
tone: "danger",
|
|
140
|
+
title: ctx.t("pill.stop"),
|
|
141
|
+
run: toggle,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (status === "error") {
|
|
146
|
+
return {
|
|
147
|
+
label: ctx.t("pill.error"),
|
|
148
|
+
icon: "outline-wave-audio",
|
|
149
|
+
tone: "warning",
|
|
150
|
+
title: ctx.t("pill.title"),
|
|
151
|
+
run: toggle,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { label: ctx.t("pill.idle"), icon: "outline-wave-audio", title: ctx.t("pill.title"), run: toggle }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
ctx.add(ComposerPill, () => ({ id: "dictate", show: () => view() }))
|
|
159
|
+
|
|
160
|
+
ctx.add(ComposerPill, () => ({
|
|
161
|
+
id: "read",
|
|
162
|
+
order: 1,
|
|
163
|
+
show: () => {
|
|
164
|
+
if (ctx.app.platform !== "desktop") return undefined
|
|
165
|
+
|
|
166
|
+
return speaker.speaking()
|
|
167
|
+
? {
|
|
168
|
+
label: ctx.t("read.speaking"),
|
|
169
|
+
icon: "outline-audio",
|
|
170
|
+
tone: "success",
|
|
171
|
+
title: ctx.t("read.stop"),
|
|
172
|
+
run: read,
|
|
173
|
+
}
|
|
174
|
+
: {
|
|
175
|
+
label: ctx.t("read.idle"),
|
|
176
|
+
icon: "outline-audio",
|
|
177
|
+
title: ctx.t("read.title"),
|
|
178
|
+
run: read,
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
}))
|
|
182
|
+
|
|
183
|
+
ctx.add(Command, (): Command => ({
|
|
184
|
+
id: "toggle",
|
|
185
|
+
title: ctx.t("command.toggle"),
|
|
186
|
+
description: ctx.t("command.toggle.description"),
|
|
187
|
+
keywords: "voice dictation microphone speech dictado voz microfono",
|
|
188
|
+
group: ctx.t("pill.title"),
|
|
189
|
+
section: "prompt",
|
|
190
|
+
bind: "mod+shift+m",
|
|
191
|
+
slash: { name: "dictado" },
|
|
192
|
+
editable: true,
|
|
193
|
+
enabled: ctx.app.platform === "desktop",
|
|
194
|
+
run: toggle,
|
|
195
|
+
}))
|
|
196
|
+
|
|
197
|
+
ctx.add(Command, (): Command => ({
|
|
198
|
+
id: "configure",
|
|
199
|
+
title: ctx.t("command.configure"),
|
|
200
|
+
keywords: "voice dictation microphone speech dictado voz microfono",
|
|
201
|
+
group: ctx.t("pill.title"),
|
|
202
|
+
enabled: ctx.app.platform === "desktop",
|
|
203
|
+
run: () => ctx.layout.settings(ctx.id),
|
|
204
|
+
}))
|
|
205
|
+
|
|
206
|
+
ctx.add(Command, (): Command => ({
|
|
207
|
+
id: "read",
|
|
208
|
+
title: ctx.t("command.read"),
|
|
209
|
+
description: ctx.t("command.read.description"),
|
|
210
|
+
keywords: "read aloud speech voice leer voz altavoz lectura",
|
|
211
|
+
group: ctx.t("pill.title"),
|
|
212
|
+
section: "prompt",
|
|
213
|
+
bind: "mod+shift+l",
|
|
214
|
+
slash: { name: "leer" },
|
|
215
|
+
editable: true,
|
|
216
|
+
enabled: ctx.app.platform === "desktop",
|
|
217
|
+
run: read,
|
|
218
|
+
}))
|
|
219
|
+
|
|
220
|
+
ctx.add(SettingsPage, {
|
|
221
|
+
id: ctx.id,
|
|
222
|
+
icon: "outline-audio",
|
|
223
|
+
get title() {
|
|
224
|
+
return ctx.t("settings.title")
|
|
225
|
+
},
|
|
226
|
+
get entries() {
|
|
227
|
+
return [
|
|
228
|
+
{ id: "language", title: ctx.t("settings.language"), keywords: "voice language idioma voz" },
|
|
229
|
+
{ id: "confidence", title: ctx.t("settings.confidence"), keywords: "voice confidence confianza" },
|
|
230
|
+
{ id: "test", title: ctx.t("settings.test"), keywords: "voice microphone microphone test microfono" },
|
|
231
|
+
{ id: "voice", title: ctx.t("speech.voice"), keywords: "read aloud voice voz lectura altavoz" },
|
|
232
|
+
{ id: "rate", title: ctx.t("speech.rate"), keywords: "read aloud speed velocidad lectura" },
|
|
233
|
+
{ id: "skip-code", title: ctx.t("speech.skipCode"), keywords: "read aloud code codigo lectura" },
|
|
234
|
+
{ id: "preview", title: ctx.t("speech.preview"), keywords: "read aloud test voice probar voz" },
|
|
235
|
+
]
|
|
236
|
+
},
|
|
237
|
+
render: () => <VoiceSettings speaker={speaker} />,
|
|
238
|
+
})
|
|
239
|
+
}
|
package/src/gui.tsx
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server part of the plugin.
|
|
5
|
+
*
|
|
6
|
+
* It does nothing on its own: it exists so the desktop app assigns the plugin
|
|
7
|
+
* the id `voice-dictation` (used for commands, settings pages and the Ipc
|
|
8
|
+
* contract) and so the GUI part is loaded from the global `opencode.json`.
|
|
9
|
+
*
|
|
10
|
+
* `@opencode/plugin` is a type-only import on purpose: the server loads this
|
|
11
|
+
* file directly, and a runtime import would make the plugin depend on a
|
|
12
|
+
* `node_modules` tree next to it.
|
|
13
|
+
*/
|
|
14
|
+
const plugin: Plugin.Plugin = {
|
|
15
|
+
id: "voice-dictation",
|
|
16
|
+
setup() {},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default plugin
|