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,87 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
import { Ipc } from "@opencode/plugin/gui"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridge between the window setup and the Electron main process.
|
|
6
|
+
*
|
|
7
|
+
* The engine lives in `main` because it needs `node:child_process`; the window
|
|
8
|
+
* only paints status and inserts the transcript.
|
|
9
|
+
*/
|
|
10
|
+
export const Dictation = Ipc.define({
|
|
11
|
+
id: "voice-dictation.dictation",
|
|
12
|
+
state: Schema.Struct({
|
|
13
|
+
status: Schema.Literals(["idle", "starting", "listening", "error"]),
|
|
14
|
+
error: Schema.String,
|
|
15
|
+
locale: Schema.String,
|
|
16
|
+
}),
|
|
17
|
+
methods: {
|
|
18
|
+
start: {
|
|
19
|
+
input: Schema.Struct({
|
|
20
|
+
locale: Schema.String,
|
|
21
|
+
minConfidence: Schema.Number,
|
|
22
|
+
}),
|
|
23
|
+
},
|
|
24
|
+
stop: {},
|
|
25
|
+
diagnose: {
|
|
26
|
+
output: Schema.Struct({
|
|
27
|
+
locales: Schema.Array(Schema.String),
|
|
28
|
+
microphone: Schema.Boolean,
|
|
29
|
+
selected: Schema.String,
|
|
30
|
+
error: Schema.String,
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
events: {
|
|
35
|
+
final: Schema.Struct({
|
|
36
|
+
text: Schema.String,
|
|
37
|
+
confidence: Schema.Number,
|
|
38
|
+
}),
|
|
39
|
+
error: Schema.Struct({
|
|
40
|
+
code: Schema.String,
|
|
41
|
+
detail: Schema.String,
|
|
42
|
+
available: Schema.Array(Schema.String),
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export interface DiagnoseResult {
|
|
48
|
+
locales: string[]
|
|
49
|
+
microphone: boolean
|
|
50
|
+
selected: string
|
|
51
|
+
error: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Text-to-speech bridge. The window speaks with `speechSynthesis` when the
|
|
56
|
+
* Electron build exposes voices, and falls back to this main-process engine
|
|
57
|
+
* (`System.Speech.Synthesis`) when it does not.
|
|
58
|
+
*/export const Speech = Ipc.define({
|
|
59
|
+
id: "voice-dictation.speech",
|
|
60
|
+
methods: {
|
|
61
|
+
speak: {
|
|
62
|
+
input: Schema.Struct({
|
|
63
|
+
text: Schema.String,
|
|
64
|
+
rate: Schema.Number,
|
|
65
|
+
voice: Schema.String,
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
stop: {},
|
|
69
|
+
voices: {
|
|
70
|
+
output: Schema.Array(Schema.String),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
events: {
|
|
74
|
+
done: Schema.Struct({ ok: Schema.Boolean }),
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Windows 11 voice typing. The window focuses the composer and asks main to
|
|
80
|
+
* press Win+H; Windows writes the text straight into the focused field.
|
|
81
|
+
*/
|
|
82
|
+
export const VoiceTyping = Ipc.define({
|
|
83
|
+
id: "voice-dictation.voice-typing",
|
|
84
|
+
methods: {
|
|
85
|
+
toggle: {},
|
|
86
|
+
},
|
|
87
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
import { Plugin, Store } from "@opencode/plugin/gui"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Declarations read by the desktop app before either setup runs.
|
|
6
|
+
*
|
|
7
|
+
* `os: ["windows"]` keeps the plugin from loading on macOS/Linux, where
|
|
8
|
+
* System.Speech does not exist. Settings shows it as not available instead of
|
|
9
|
+
* failing.
|
|
10
|
+
*/
|
|
11
|
+
export const definition = Plugin.define({
|
|
12
|
+
os: ["windows"],
|
|
13
|
+
stores: {
|
|
14
|
+
prefs: Store.global(
|
|
15
|
+
Schema.Struct({
|
|
16
|
+
locale: Schema.String,
|
|
17
|
+
minConfidence: Schema.Number,
|
|
18
|
+
trailingSpace: Schema.Boolean,
|
|
19
|
+
autoFocus: Schema.Boolean,
|
|
20
|
+
}),
|
|
21
|
+
{
|
|
22
|
+
locale: "es-ES",
|
|
23
|
+
minConfidence: 0.4,
|
|
24
|
+
trailingSpace: true,
|
|
25
|
+
autoFocus: true,
|
|
26
|
+
},
|
|
27
|
+
),
|
|
28
|
+
speech: Store.global(
|
|
29
|
+
Schema.Struct({
|
|
30
|
+
voiceURI: Schema.String,
|
|
31
|
+
rate: Schema.Number,
|
|
32
|
+
skipCode: Schema.Boolean,
|
|
33
|
+
}),
|
|
34
|
+
{
|
|
35
|
+
voiceURI: "",
|
|
36
|
+
rate: 1,
|
|
37
|
+
skipCode: true,
|
|
38
|
+
},
|
|
39
|
+
),
|
|
40
|
+
engine: Store.global(
|
|
41
|
+
Schema.Struct({
|
|
42
|
+
mode: Schema.String,
|
|
43
|
+
}),
|
|
44
|
+
{
|
|
45
|
+
mode: "windows",
|
|
46
|
+
},
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
i18n: {
|
|
50
|
+
en: {
|
|
51
|
+
"pill.idle": "Dictate",
|
|
52
|
+
"pill.starting": "Starting…",
|
|
53
|
+
"pill.listening": "Listening…",
|
|
54
|
+
"pill.dictating": "Dictating…",
|
|
55
|
+
"pill.error": "Retry dictation",
|
|
56
|
+
"pill.title": "Voice dictation",
|
|
57
|
+
"pill.winH": "Open Windows voice typing (Win+H)",
|
|
58
|
+
"pill.stop": "Stop dictation",
|
|
59
|
+
"command.toggle": "Toggle voice dictation",
|
|
60
|
+
"command.toggle.description": "Dictate into the prompt with the selected engine",
|
|
61
|
+
"command.configure": "Voice dictation settings",
|
|
62
|
+
"error.title": "Voice dictation",
|
|
63
|
+
"error.language": "The speech language {locale} is not installed. Available: {available}.",
|
|
64
|
+
"error.none": "Windows has no speech recognizer installed. Settings › Time & language › Language › Speech.",
|
|
65
|
+
"error.mic": "No microphone is available for speech recognition.",
|
|
66
|
+
"error.system": "System.Speech is not available in this Windows installation.",
|
|
67
|
+
"error.exit": "The dictation engine stopped unexpectedly.",
|
|
68
|
+
"error.unknown": "Dictation failed ({code}).",
|
|
69
|
+
"toast.discarded": "Dictation discarded: the active session changed.",
|
|
70
|
+
"settings.title": "Voice dictation",
|
|
71
|
+
"settings.description": "Offline dictation with the Windows speech recognizer. The text is inserted into the prompt; it is never sent automatically.",
|
|
72
|
+
"settings.section": "Dictation",
|
|
73
|
+
"settings.engine": "Dictation engine",
|
|
74
|
+
"settings.engine.description": "Windows uses Windows 11 voice typing (Win+H), which is more accurate. System.Speech works offline but is less precise.",
|
|
75
|
+
"engine.windows": "Windows Voice Typing (Win+H)",
|
|
76
|
+
"engine.system": "System.Speech (offline)",
|
|
77
|
+
"settings.winh": "Test Win+H",
|
|
78
|
+
"settings.winh.description": "Sends the Windows voice typing shortcut. If the floating bar appears, it works.",
|
|
79
|
+
"settings.winh.run": "Test Win+H",
|
|
80
|
+
"settings.winh.sent": "Win+H sent. If the Windows dictation bar did not appear, check Settings › Accessibility › Voice typing.",
|
|
81
|
+
"error.winh": "Windows voice typing could not be triggered.",
|
|
82
|
+
"settings.language": "Language",
|
|
83
|
+
"settings.language.description": "A speech recognizer installed in Windows. Accents and punctuation depend on it.",
|
|
84
|
+
"settings.confidence": "Minimum confidence",
|
|
85
|
+
"settings.confidence.description": "Results below this threshold are ignored to avoid noise.",
|
|
86
|
+
"confidence.low": "Low (0.2)",
|
|
87
|
+
"confidence.normal": "Normal (0.4)",
|
|
88
|
+
"confidence.high": "High (0.6)",
|
|
89
|
+
"settings.space": "Add a space before the text",
|
|
90
|
+
"settings.space.description": "Separates the dictation from what is already in the prompt.",
|
|
91
|
+
"settings.focus": "Focus the prompt after dictating",
|
|
92
|
+
"settings.focus.description": "Moves the cursor to the prompt so you can review or send it.",
|
|
93
|
+
"settings.test.section": "Diagnostics",
|
|
94
|
+
"settings.test": "Test microphone",
|
|
95
|
+
"settings.test.description": "Checks installed recognizers and the default microphone.",
|
|
96
|
+
"settings.test.run": "Run test",
|
|
97
|
+
"settings.test.success": "Speech recognition ready: {locale}",
|
|
98
|
+
"settings.test.failed": "Not ready: {reason}",
|
|
99
|
+
"common.reason.no-recognizers": "no speech language installed",
|
|
100
|
+
"common.reason.no-microphone": "no microphone available",
|
|
101
|
+
"read.idle": "Read",
|
|
102
|
+
"read.speaking": "Reading…",
|
|
103
|
+
"read.stop": "Stop reading",
|
|
104
|
+
"read.title": "Read the last message aloud",
|
|
105
|
+
"command.read": "Read the last message aloud",
|
|
106
|
+
"command.read.description": "Speaks the last reply with a system voice",
|
|
107
|
+
"read.noSession": "Open a conversation to read its last message.",
|
|
108
|
+
"read.empty": "The last message has no text to read.",
|
|
109
|
+
"read.error": "The last message could not be read.",
|
|
110
|
+
"read.noVoices": "Windows has no voices installed for reading.",
|
|
111
|
+
"speech.section": "Read aloud",
|
|
112
|
+
"speech.voice": "Voice",
|
|
113
|
+
"speech.voice.description": "A system voice. Automatic picks one in the app language.",
|
|
114
|
+
"speech.voice.auto": "Automatic",
|
|
115
|
+
"speech.rate": "Speed",
|
|
116
|
+
"speech.rate.description": "How fast the text is spoken.",
|
|
117
|
+
"speech.rate.slow": "0.8×",
|
|
118
|
+
"speech.rate.normal": "1×",
|
|
119
|
+
"speech.rate.fast": "1.2×",
|
|
120
|
+
"speech.rate.faster": "1.5×",
|
|
121
|
+
"speech.skipCode": "Skip code blocks",
|
|
122
|
+
"speech.skipCode.description": "Read only the prose; code, paths and commands are left out.",
|
|
123
|
+
"speech.preview": "Test voice",
|
|
124
|
+
"speech.preview.description": "Speaks a short sample with the current voice and speed.",
|
|
125
|
+
"speech.preview.text": "Hello, this is a reading test.",
|
|
126
|
+
},
|
|
127
|
+
es: {
|
|
128
|
+
"pill.idle": "Dictar",
|
|
129
|
+
"pill.starting": "Iniciando…",
|
|
130
|
+
"pill.listening": "Escuchando…",
|
|
131
|
+
"pill.dictating": "Dictando…",
|
|
132
|
+
"pill.error": "Reintentar dictado",
|
|
133
|
+
"pill.title": "Dictado por voz",
|
|
134
|
+
"pill.winH": "Abrir el dictado de Windows (Win+H)",
|
|
135
|
+
"pill.stop": "Detener dictado",
|
|
136
|
+
"command.toggle": "Alternar dictado por voz",
|
|
137
|
+
"command.toggle.description": "Dicta en el prompt con el motor elegido",
|
|
138
|
+
"command.configure": "Ajustes de dictado por voz",
|
|
139
|
+
"error.title": "Dictado por voz",
|
|
140
|
+
"error.language": "El idioma de reconocimiento {locale} no está instalado. Disponibles: {available}.",
|
|
141
|
+
"error.none": "Windows no tiene ningún reconocedor de voz instalado. Configuración › Hora e idioma › Idioma › Voz.",
|
|
142
|
+
"error.mic": "No hay ningún micrófono disponible para el reconocimiento de voz.",
|
|
143
|
+
"error.system": "System.Speech no está disponible en esta instalación de Windows.",
|
|
144
|
+
"error.exit": "El motor de dictado se detuvo inesperadamente.",
|
|
145
|
+
"error.unknown": "El dictado falló ({code}).",
|
|
146
|
+
"toast.discarded": "Dictado descartado: la sesión activa cambió.",
|
|
147
|
+
"settings.title": "Dictado por voz",
|
|
148
|
+
"settings.description": "Dictado offline con el reconocedor de voz de Windows. El texto se inserta en el prompt; nunca se envía solo.",
|
|
149
|
+
"settings.section": "Dictado",
|
|
150
|
+
"settings.engine": "Motor de dictado",
|
|
151
|
+
"settings.engine.description": "Windows usa el dictado de Windows 11 (Win+H), más preciso. System.Speech funciona sin Windows 11 pero es menos exacto.",
|
|
152
|
+
"engine.windows": "Windows Voice Typing (Win+H)",
|
|
153
|
+
"engine.system": "System.Speech (offline)",
|
|
154
|
+
"settings.winh": "Probar Win+H",
|
|
155
|
+
"settings.winh.description": "Envía el atajo del dictado de Windows. Si aparece la barra flotante, funciona.",
|
|
156
|
+
"settings.winh.run": "Probar Win+H",
|
|
157
|
+
"settings.winh.sent": "Win+H enviado. Si no apareció la barra, revisa Configuración › Accesibilidad › Dictado por voz.",
|
|
158
|
+
"settings.language": "Idioma",
|
|
159
|
+
"settings.language.description": "Un reconocedor instalado en Windows. Los acentos y la puntuación dependen de él.",
|
|
160
|
+
"settings.confidence": "Confianza mínima",
|
|
161
|
+
"settings.confidence.description": "Los resultados por debajo de este umbral se ignoran para evitar ruido.",
|
|
162
|
+
"confidence.low": "Baja (0,2)",
|
|
163
|
+
"confidence.normal": "Normal (0,4)",
|
|
164
|
+
"confidence.high": "Alta (0,6)",
|
|
165
|
+
"settings.space": "Añadir un espacio antes del texto",
|
|
166
|
+
"settings.space.description": "Separa el dictado de lo que ya hay en el prompt.",
|
|
167
|
+
"settings.focus": "Enfocar el prompt al terminar",
|
|
168
|
+
"settings.focus.description": "Mueve el cursor al prompt para revisar o enviar el texto.",
|
|
169
|
+
"settings.test.section": "Diagnóstico",
|
|
170
|
+
"settings.test": "Probar micrófono",
|
|
171
|
+
"settings.test.description": "Comprueba los reconocedores instalados y el micrófono predeterminado.",
|
|
172
|
+
"settings.test.run": "Ejecutar prueba",
|
|
173
|
+
"settings.test.success": "Reconocimiento de voz listo: {locale}",
|
|
174
|
+
"settings.test.failed": "No está listo: {reason}",
|
|
175
|
+
"common.reason.no-recognizers": "no hay ningún idioma de voz instalado",
|
|
176
|
+
"common.reason.no-microphone": "no hay micrófono disponible",
|
|
177
|
+
"read.idle": "Leer",
|
|
178
|
+
"read.speaking": "Leyendo…",
|
|
179
|
+
"read.stop": "Detener lectura",
|
|
180
|
+
"read.title": "Leer en voz alta el último mensaje",
|
|
181
|
+
"command.read": "Leer en voz alta el último mensaje",
|
|
182
|
+
"command.read.description": "Reproduce la última respuesta con una voz del sistema",
|
|
183
|
+
"read.noSession": "Abre una conversación para leer su último mensaje.",
|
|
184
|
+
"read.empty": "El último mensaje no tiene texto para leer.",
|
|
185
|
+
"read.error": "No se pudo leer el último mensaje.",
|
|
186
|
+
"read.noVoices": "Windows no tiene voces instaladas para la lectura.",
|
|
187
|
+
"speech.section": "Lectura en voz alta",
|
|
188
|
+
"speech.voice": "Voz",
|
|
189
|
+
"speech.voice.description": "Una voz del sistema. Automática elige una en el idioma de la app.",
|
|
190
|
+
"speech.voice.auto": "Automática",
|
|
191
|
+
"speech.rate": "Velocidad",
|
|
192
|
+
"speech.rate.description": "A qué ritmo se lee el texto.",
|
|
193
|
+
"speech.rate.slow": "0,8×",
|
|
194
|
+
"speech.rate.normal": "1×",
|
|
195
|
+
"speech.rate.fast": "1,2×",
|
|
196
|
+
"speech.rate.faster": "1,5×",
|
|
197
|
+
"speech.skipCode": "Ignorar bloques de código",
|
|
198
|
+
"speech.skipCode.description": "Lee solo la prosa; omite código, rutas y comandos.",
|
|
199
|
+
"speech.preview": "Probar voz",
|
|
200
|
+
"speech.preview.description": "Lee una frase corta con la voz y velocidad actuales.",
|
|
201
|
+
"speech.preview.text": "Hola, esta es una prueba de lectura.",
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
3
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { SPEAK_SCRIPT } from "./speak-script"
|
|
7
|
+
|
|
8
|
+
const POWERSHELL = "powershell.exe"
|
|
9
|
+
|
|
10
|
+
export interface SpeakInput {
|
|
11
|
+
text: string
|
|
12
|
+
rate: number
|
|
13
|
+
voice: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SpeakHandle {
|
|
17
|
+
stop(): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function workDir(): string {
|
|
21
|
+
const dir = join(tmpdir(), "opencode-voice-dictation")
|
|
22
|
+
mkdirSync(dir, { recursive: true })
|
|
23
|
+
return dir
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scriptFile(): string {
|
|
27
|
+
const hash = createHash("sha1").update(SPEAK_SCRIPT).digest("hex").slice(0, 12)
|
|
28
|
+
const file = join(workDir(), `speak-${hash}.ps1`)
|
|
29
|
+
writeFileSync(file, SPEAK_SCRIPT, "utf8")
|
|
30
|
+
return file
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function textFile(text: string): string {
|
|
34
|
+
const file = join(workDir(), `speech-${randomUUID()}.txt`)
|
|
35
|
+
writeFileSync(file, text, "utf8")
|
|
36
|
+
return file
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function spawnPowershell(args: string[]): ChildProcess {
|
|
40
|
+
return spawn(POWERSHELL, args, {
|
|
41
|
+
windowsHide: true,
|
|
42
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function killTree(child: ChildProcess) {
|
|
47
|
+
const pid = child.pid
|
|
48
|
+
if (pid === undefined) {
|
|
49
|
+
child.kill()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
stdio: "ignore",
|
|
55
|
+
})
|
|
56
|
+
killer.on("error", () => child.kill())
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Maps the Web Speech `rate` scale (0.1–10, 1 = normal) to SAPI's -10…10. */
|
|
60
|
+
function sapiRate(rate: number): number {
|
|
61
|
+
return Math.max(-10, Math.min(10, Math.round((rate - 1) * 5)))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function speakSapi(input: SpeakInput, onDone: (ok: boolean) => void): SpeakHandle {
|
|
65
|
+
const file = textFile(input.text)
|
|
66
|
+
|
|
67
|
+
const child = spawnPowershell([
|
|
68
|
+
"-NoProfile",
|
|
69
|
+
"-NonInteractive",
|
|
70
|
+
"-ExecutionPolicy",
|
|
71
|
+
"Bypass",
|
|
72
|
+
"-File",
|
|
73
|
+
scriptFile(),
|
|
74
|
+
"-TextFile",
|
|
75
|
+
file,
|
|
76
|
+
"-Rate",
|
|
77
|
+
String(sapiRate(input.rate)),
|
|
78
|
+
"-Voice",
|
|
79
|
+
input.voice,
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
let stopped = false
|
|
83
|
+
|
|
84
|
+
const clean = () => {
|
|
85
|
+
try {
|
|
86
|
+
rmSync(file, { force: true })
|
|
87
|
+
} catch {
|
|
88
|
+
// Best effort: the temp folder is cleaned by the OS anyway.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
child.on("error", () => {
|
|
93
|
+
clean()
|
|
94
|
+
if (!stopped) onDone(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
child.on("close", (code) => {
|
|
98
|
+
clean()
|
|
99
|
+
if (!stopped) onDone(code === 0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
stop() {
|
|
104
|
+
stopped = true
|
|
105
|
+
killTree(child)
|
|
106
|
+
clean()
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function listSapiVoices(): Promise<string[]> {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
const child = spawnPowershell([
|
|
114
|
+
"-NoProfile",
|
|
115
|
+
"-NonInteractive",
|
|
116
|
+
"-ExecutionPolicy",
|
|
117
|
+
"Bypass",
|
|
118
|
+
"-File",
|
|
119
|
+
scriptFile(),
|
|
120
|
+
"-ListVoices",
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
let stdout = ""
|
|
124
|
+
let settled = false
|
|
125
|
+
|
|
126
|
+
const finish = (names: string[]) => {
|
|
127
|
+
if (settled) return
|
|
128
|
+
settled = true
|
|
129
|
+
clearTimeout(timer)
|
|
130
|
+
resolve(names)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
killTree(child)
|
|
135
|
+
finish([])
|
|
136
|
+
}, 10000)
|
|
137
|
+
|
|
138
|
+
child.stdout?.setEncoding("utf8")
|
|
139
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
140
|
+
stdout += chunk
|
|
141
|
+
})
|
|
142
|
+
child.on("error", () => finish([]))
|
|
143
|
+
child.on("close", () => {
|
|
144
|
+
const names = stdout
|
|
145
|
+
.split(/\r?\n/)
|
|
146
|
+
.map((line) => line.trim())
|
|
147
|
+
.filter((line) => line.length > 0)
|
|
148
|
+
finish(names)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Windows speech engine, written to a temporary `.ps1` file at runtime.
|
|
3
|
+
*
|
|
4
|
+
* It is embedded instead of shipped as a resource so the single-file GUI
|
|
5
|
+
* bundle never has to resolve a path to it.
|
|
6
|
+
*
|
|
7
|
+
* Protocol: one JSON object per line on stdout, flushed after every event.
|
|
8
|
+
*
|
|
9
|
+
* {"type":"ready","locale":"es-ES","requested":"es-ES"}
|
|
10
|
+
* {"type":"final","text":"hola","confidence":0.82,"locale":"es-ES"}
|
|
11
|
+
* {"type":"error","code":"...","detail":"...","available":["en-US"]}
|
|
12
|
+
* {"type":"diagnose","locales":[...],"microphone":true,"selected":"es-ES","error":""}
|
|
13
|
+
*
|
|
14
|
+
* Keep the file ASCII-only: Windows PowerShell 5.1 reads a BOM-less script as
|
|
15
|
+
* ANSI. Write `$` and avoid `${...}` and backticks so it survives the template
|
|
16
|
+
* literal.
|
|
17
|
+
*/
|
|
18
|
+
export const DICTATION_SCRIPT = String.raw`param(
|
|
19
|
+
[string]$Culture = "es-ES",
|
|
20
|
+
[double]$MinConfidence = 0.4,
|
|
21
|
+
[switch]$Diagnose
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
$ErrorActionPreference = "Stop"
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
|
28
|
+
[Console]::OutputEncoding = $utf8
|
|
29
|
+
} catch { }
|
|
30
|
+
|
|
31
|
+
function Emit {
|
|
32
|
+
param([hashtable]$Payload)
|
|
33
|
+
[Console]::Out.WriteLine(($Payload | ConvertTo-Json -Compress))
|
|
34
|
+
[Console]::Out.Flush()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
Add-Type -AssemblyName System.Speech
|
|
39
|
+
} catch {
|
|
40
|
+
Emit @{ type = "error"; code = "SYSTEM_SPEECH_UNAVAILABLE"; detail = "$($_.Exception.Message)"; available = @() }
|
|
41
|
+
exit 2
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$installed = @()
|
|
45
|
+
try {
|
|
46
|
+
foreach ($entry in [System.Speech.Recognition.SpeechRecognitionEngine]::InstalledRecognizers()) {
|
|
47
|
+
$installed += $entry.Culture.Name
|
|
48
|
+
}
|
|
49
|
+
} catch { }
|
|
50
|
+
|
|
51
|
+
$selected = ""
|
|
52
|
+
foreach ($name in $installed) {
|
|
53
|
+
if ($name -eq $Culture) { $selected = $name; break }
|
|
54
|
+
}
|
|
55
|
+
if ($selected -eq "") {
|
|
56
|
+
$base = ($Culture -split "-")[0]
|
|
57
|
+
foreach ($name in $installed) {
|
|
58
|
+
if (($name -split "-")[0] -eq $base) { $selected = $name; break }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if ($Diagnose) {
|
|
63
|
+
$microphone = $false
|
|
64
|
+
$problem = ""
|
|
65
|
+
if ($installed.Count -gt 0) {
|
|
66
|
+
try {
|
|
67
|
+
$probeCulture = New-Object System.Globalization.CultureInfo($installed[0])
|
|
68
|
+
$probe = New-Object System.Speech.Recognition.SpeechRecognitionEngine($probeCulture)
|
|
69
|
+
$probe.SetInputToDefaultAudioDevice()
|
|
70
|
+
$microphone = $true
|
|
71
|
+
$probe.Dispose()
|
|
72
|
+
} catch {
|
|
73
|
+
$problem = "$($_.Exception.Message)"
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
$problem = "NO_RECOGNIZERS"
|
|
77
|
+
}
|
|
78
|
+
Emit @{ type = "diagnose"; locales = @($installed); microphone = $microphone; selected = $selected; error = $problem }
|
|
79
|
+
exit 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if ($installed.Count -eq 0) {
|
|
83
|
+
Emit @{ type = "error"; code = "NO_RECOGNIZERS"; detail = ""; available = @() }
|
|
84
|
+
exit 3
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if ($selected -eq "") {
|
|
88
|
+
Emit @{ type = "error"; code = "LANGUAGE_NOT_AVAILABLE"; detail = $Culture; available = @($installed) }
|
|
89
|
+
exit 4
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
$cultureInfo = New-Object System.Globalization.CultureInfo($selected)
|
|
94
|
+
$recognizer = New-Object System.Speech.Recognition.SpeechRecognitionEngine($cultureInfo)
|
|
95
|
+
$recognizer.LoadGrammar((New-Object System.Speech.Recognition.DictationGrammar))
|
|
96
|
+
$recognizer.InitialSilenceTimeout = [TimeSpan]::FromSeconds(10)
|
|
97
|
+
$recognizer.BabbleTimeout = [TimeSpan]::FromSeconds(5)
|
|
98
|
+
$recognizer.EndSilenceTimeout = [TimeSpan]::FromSeconds(0.6)
|
|
99
|
+
$recognizer.EndSilenceTimeoutAmbiguous = [TimeSpan]::FromSeconds(1.2)
|
|
100
|
+
$recognizer.SetInputToDefaultAudioDevice()
|
|
101
|
+
} catch {
|
|
102
|
+
Emit @{ type = "error"; code = "MICROPHONE_UNAVAILABLE"; detail = "$($_.Exception.Message)"; available = @($installed) }
|
|
103
|
+
exit 5
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
Emit @{ type = "ready"; locale = $selected; requested = $Culture }
|
|
107
|
+
|
|
108
|
+
$failures = 0
|
|
109
|
+
while ($true) {
|
|
110
|
+
try {
|
|
111
|
+
$result = $recognizer.Recognize([TimeSpan]::FromSeconds(5))
|
|
112
|
+
$failures = 0
|
|
113
|
+
if ($result -ne $null -and $result.Text -ne "" -and $result.Confidence -ge $MinConfidence) {
|
|
114
|
+
Emit @{ type = "final"; text = $result.Text; confidence = [math]::Round($result.Confidence, 3); locale = $selected }
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
$failures++
|
|
118
|
+
if ($failures -ge 5) {
|
|
119
|
+
Emit @{ type = "error"; code = "RECOGNITION_FAILED"; detail = "$($_.Exception.Message)"; available = @() }
|
|
120
|
+
exit 6
|
|
121
|
+
}
|
|
122
|
+
Start-Sleep -Milliseconds 250
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
`
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-to-speech fallback with `System.Speech.Synthesis`, written to a
|
|
3
|
+
* temporary `.ps1` file at runtime.
|
|
4
|
+
*
|
|
5
|
+
* It reads the text from a UTF-8 file instead of the command line, so long
|
|
6
|
+
* messages and any character (accents, quotes, newlines) travel intact.
|
|
7
|
+
*
|
|
8
|
+
* `-ListVoices` prints one installed voice name per line.
|
|
9
|
+
*
|
|
10
|
+
* Keep the file ASCII-only: Windows PowerShell 5.1 reads a BOM-less script as
|
|
11
|
+
* ANSI.
|
|
12
|
+
*/
|
|
13
|
+
export const SPEAK_SCRIPT = String.raw`param(
|
|
14
|
+
[string]$TextFile = "",
|
|
15
|
+
[int]$Rate = 0,
|
|
16
|
+
[string]$Voice = "",
|
|
17
|
+
[switch]$ListVoices
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
$ErrorActionPreference = "Stop"
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
|
24
|
+
[Console]::OutputEncoding = $utf8
|
|
25
|
+
} catch { }
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
Add-Type -AssemblyName System.Speech
|
|
29
|
+
} catch {
|
|
30
|
+
exit 2
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
35
|
+
} catch {
|
|
36
|
+
exit 3
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if ($ListVoices) {
|
|
40
|
+
try {
|
|
41
|
+
foreach ($entry in $synth.GetInstalledVoices()) {
|
|
42
|
+
if ($entry.Enabled) {
|
|
43
|
+
[Console]::Out.WriteLine($entry.VoiceInfo.Name)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch { }
|
|
47
|
+
$synth.Dispose()
|
|
48
|
+
exit 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if ($Rate -lt -10) { $Rate = -10 }
|
|
52
|
+
if ($Rate -gt 10) { $Rate = 10 }
|
|
53
|
+
|
|
54
|
+
try { $synth.Rate = $Rate } catch { }
|
|
55
|
+
|
|
56
|
+
if ($Voice -ne "") {
|
|
57
|
+
try { $synth.SelectVoice($Voice) } catch { }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
$text = [System.IO.File]::ReadAllText($TextFile, [System.Text.Encoding]::UTF8)
|
|
62
|
+
$synth.Speak($text)
|
|
63
|
+
} catch { }
|
|
64
|
+
|
|
65
|
+
$synth.Dispose()
|
|
66
|
+
`
|