opencode-voice-dictation 0.8.1 → 0.9.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/README.md +19 -4
- package/package.json +12 -6
- package/src/gui/main.ts +2 -2
- package/src/gui/speak.ts +2 -75
- package/src/{gui/engines/sapi-speech.ts → sapi-speech.ts} +1 -1
- package/src/speech-text.ts +75 -0
- package/src/tui/player.ts +128 -0
- package/src/tui.ts +253 -0
- /package/src/{gui/engines/speak-script.ts → speak-script.ts} +0 -0
- /package/src/{gui/engines/win-h.ts → win-h.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
# Dictado por voz para OpenCode Desktop
|
|
1
|
+
# Dictado por voz para OpenCode Desktop y TUI
|
|
2
2
|
|
|
3
3
|
Plugin de [OpenCode](https://opencode.ai) V2 que añade voz a la app de
|
|
4
|
-
escritorio:
|
|
4
|
+
escritorio y a la TUI:
|
|
5
5
|
|
|
6
6
|
- **Dictado**: pulsas la píldora y se abre el dictado de Windows 11 (Win+H),
|
|
7
7
|
que escribe en el prompt con su motor neuronal. También puedes usar el motor
|
|
8
8
|
offline `System.Speech` desde Ajustes.
|
|
9
9
|
- **Lectura**: pulsa «Leer» y reproduce en voz alta la última respuesta.
|
|
10
|
+
- **TUI**: usa `/dictado` y `/leer` (o `<leader>d` y `<leader>v`) en la terminal.
|
|
10
11
|
|
|
11
12
|
- **Dictado preciso**: por defecto delega en Windows Voice Typing (Win+H).
|
|
12
13
|
- **Modo offline**: `System.Speech` sigue disponible como alternativa sin
|
|
@@ -28,8 +29,8 @@ escritorio:
|
|
|
28
29
|
opencode plugin add opencode-voice-dictation
|
|
29
30
|
```
|
|
30
31
|
|
|
31
|
-
Reinicia OpenCode Desktop. El plugin queda registrado en el
|
|
32
|
-
global del entorno donde ejecutes el comando.
|
|
32
|
+
Reinicia OpenCode Desktop o la TUI. El plugin queda registrado en el
|
|
33
|
+
`opencode.json` global del entorno donde ejecutes el comando.
|
|
33
34
|
|
|
34
35
|
Actualizar:
|
|
35
36
|
|
|
@@ -44,6 +45,20 @@ está commiteado):
|
|
|
44
45
|
opencode plugin add github:evious/opencode-voice-dictation
|
|
45
46
|
```
|
|
46
47
|
|
|
48
|
+
## TUI (terminal)
|
|
49
|
+
|
|
50
|
+
El paquete también exporta una parte TUI. En Windows puedes dictar y escuchar
|
|
51
|
+
la última respuesta sin salir de la terminal:
|
|
52
|
+
|
|
53
|
+
| Comando | Atajo | Acción |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `/dictado` | `<leader>d` (`Ctrl+X`, luego `d`) | Abre el dictado de Windows (Win+H) en el prompt. |
|
|
56
|
+
| `/leer` | `<leader>v` (`Ctrl+X`, luego `v`) | Lee la última respuesta. Si ya está leyendo, la detiene. |
|
|
57
|
+
|
|
58
|
+
La lectura usa las voces neuronales de Edge y, si falla la red o la
|
|
59
|
+
reproducción, cae en la voz del sistema. El dictado de la TUI requiere Windows
|
|
60
|
+
por ahora; Linux se añadirá más adelante.
|
|
61
|
+
|
|
47
62
|
## Compatibilidad
|
|
48
63
|
|
|
49
64
|
Probado con OpenCode Desktop **2.2.6** en Windows 11 y Ubuntu 24.04. La API de
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-voice-dictation",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Offline voice dictation and read-aloud for OpenCode Desktop on Windows and Linux",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Offline voice dictation and read-aloud for OpenCode Desktop and TUI on Windows and Linux",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Fran Carlos <francarlos803@gmail.com>",
|
|
7
7
|
"homepage": "https://github.com/evious/opencode-voice-dictation#readme",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"keywords": [
|
|
16
16
|
"opencode",
|
|
17
17
|
"opencode-plugin",
|
|
18
|
+
"tui",
|
|
19
|
+
"terminal",
|
|
18
20
|
"voice",
|
|
19
21
|
"dictation",
|
|
20
22
|
"speech-to-text",
|
|
@@ -28,7 +30,8 @@
|
|
|
28
30
|
"exports": {
|
|
29
31
|
".": "./src/index.ts",
|
|
30
32
|
"./gui-experimental": "./dist/gui-experimental.cjs",
|
|
31
|
-
"./rpc": "./src/rpc.ts"
|
|
33
|
+
"./rpc": "./src/rpc.ts",
|
|
34
|
+
"./tui": "./src/tui.ts"
|
|
32
35
|
},
|
|
33
36
|
"files": [
|
|
34
37
|
"src",
|
|
@@ -38,8 +41,8 @@
|
|
|
38
41
|
"docs"
|
|
39
42
|
],
|
|
40
43
|
"dependencies": {
|
|
41
|
-
"
|
|
42
|
-
"
|
|
44
|
+
"@opencode/plugin": "2.2.6",
|
|
45
|
+
"msedge-tts": "^2.1.1"
|
|
43
46
|
},
|
|
44
47
|
"publishConfig": {
|
|
45
48
|
"access": "public"
|
|
@@ -47,11 +50,14 @@
|
|
|
47
50
|
"scripts": {
|
|
48
51
|
"build": "vite build",
|
|
49
52
|
"dev": "vite build --watch",
|
|
50
|
-
"test": "node test/smoke.cjs",
|
|
53
|
+
"test": "node test/speech-text.cjs && node test/tui.cjs && node test/smoke.cjs",
|
|
51
54
|
"typecheck": "tsc --noEmit",
|
|
52
55
|
"prepublishOnly": "npm run typecheck && npm run build && npm test"
|
|
53
56
|
},
|
|
54
57
|
"devDependencies": {
|
|
58
|
+
"@opencode/theme": "^2.2.6",
|
|
59
|
+
"@opentui/core": "^0.8.1",
|
|
60
|
+
"@opentui/solid": "^0.8.1",
|
|
55
61
|
"@types/node": "^24.0.0",
|
|
56
62
|
"effect": "4.1.0",
|
|
57
63
|
"solid-js": "^1.9.17",
|
package/src/gui/main.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode/plugin/gui"
|
|
2
2
|
import { Dictation, Speech, VoiceTyping } from "./contract"
|
|
3
3
|
import { diagnose, startEngine, type EngineHandle } from "./engines/system-speech"
|
|
4
|
-
import { listSapiVoices, speakSapi, type SpeakHandle } from "
|
|
5
|
-
import { sendVoiceTypingShortcut } from "
|
|
4
|
+
import { listSapiVoices, speakSapi, type SpeakHandle } from "../sapi-speech"
|
|
5
|
+
import { sendVoiceTypingShortcut } from "../win-h"
|
|
6
6
|
|
|
7
7
|
type Status = "idle" | "starting" | "listening" | "error"
|
|
8
8
|
|
package/src/gui/speak.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createSignal } from "solid-js"
|
|
2
2
|
import type { IpcClient, SetupContext } from "@opencode/plugin/gui"
|
|
3
3
|
import { VoiceRpc } from "../rpc"
|
|
4
|
+
import { cleanForSpeech, chunkForSpeech } from "../speech-text"
|
|
4
5
|
import { Speech } from "./contract"
|
|
5
6
|
import type { definition } from "./definition"
|
|
6
7
|
|
|
@@ -30,81 +31,7 @@ export interface Speaker {
|
|
|
30
31
|
preview(): Promise<boolean>
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
/** Turns Markdown into something worth hearing. */
|
|
36
|
-
export function cleanForSpeech(text: string, skipCode: boolean): string {
|
|
37
|
-
let value = text.replace(/\r\n/g, "\n")
|
|
38
|
-
|
|
39
|
-
if (skipCode) {
|
|
40
|
-
value = value.replace(/```[\s\S]*?```/g, " ")
|
|
41
|
-
} else {
|
|
42
|
-
value = value.replace(/```[^\n]*\n([\s\S]*?)```/g, "$1")
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
value = value.replace(/`([^`]+)`/g, "$1")
|
|
46
|
-
value = value.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
|
47
|
-
value = value.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
48
|
-
value = value.replace(/^\s*#{1,6}\s+/gm, "")
|
|
49
|
-
value = value.replace(/^\s*>\s?/gm, "")
|
|
50
|
-
value = value.replace(/^\s*[-*+]\s+/gm, "")
|
|
51
|
-
value = value.replace(/^\s*\d+\.\s+/gm, "")
|
|
52
|
-
value = value.replace(/(\*\*|__)(.*?)\1/g, "$2")
|
|
53
|
-
value = value.replace(/(\*|_)(.*?)\1/g, "$2")
|
|
54
|
-
value = value.replace(/^\s*\|.*\|\s*$/gm, " ")
|
|
55
|
-
value = value.replace(/\n{2,}/g, ". ")
|
|
56
|
-
value = value.replace(/\n/g, " ")
|
|
57
|
-
value = value.replace(/\s+/g, " ")
|
|
58
|
-
value = value.replace(/\s+([.,;:!?])/g, "$1")
|
|
59
|
-
value = value.replace(/([.,;:!?]){2,}/g, "$1")
|
|
60
|
-
|
|
61
|
-
return value.trim()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Splits at sentence boundaries, then at word boundaries for long sentences. */
|
|
65
|
-
export function chunkForSpeech(text: string, max = MAX_CHUNK): string[] {
|
|
66
|
-
const trimmed = text.trim()
|
|
67
|
-
if (!trimmed) return []
|
|
68
|
-
|
|
69
|
-
const sentences = trimmed.split(/(?<=[.!?…])\s+/)
|
|
70
|
-
const chunks: string[] = []
|
|
71
|
-
let current = ""
|
|
72
|
-
|
|
73
|
-
const pushWords = (sentence: string) => {
|
|
74
|
-
let part = ""
|
|
75
|
-
for (const word of sentence.split(/\s+/)) {
|
|
76
|
-
if (!word) continue
|
|
77
|
-
if (part && part.length + 1 + word.length > max) {
|
|
78
|
-
chunks.push(part)
|
|
79
|
-
part = word
|
|
80
|
-
} else {
|
|
81
|
-
part = part ? `${part} ${word}` : word
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return part
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
for (const sentence of sentences) {
|
|
88
|
-
if (sentence.length > max) {
|
|
89
|
-
if (current) {
|
|
90
|
-
chunks.push(current)
|
|
91
|
-
current = ""
|
|
92
|
-
}
|
|
93
|
-
current = pushWords(sentence)
|
|
94
|
-
continue
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (current && current.length + 1 + sentence.length > max) {
|
|
98
|
-
chunks.push(current)
|
|
99
|
-
current = sentence
|
|
100
|
-
} else {
|
|
101
|
-
current = current ? `${current} ${sentence}` : sentence
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (current) chunks.push(current)
|
|
106
|
-
return chunks
|
|
107
|
-
}
|
|
34
|
+
export { cleanForSpeech, chunkForSpeech } from "../speech-text"
|
|
108
35
|
|
|
109
36
|
export function createSpeaker(
|
|
110
37
|
ctx: SetupContext<typeof definition>,
|
|
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto"
|
|
|
3
3
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
4
4
|
import { tmpdir } from "node:os"
|
|
5
5
|
import { join } from "node:path"
|
|
6
|
-
import { SPEAK_SCRIPT } from "./speak-script"
|
|
6
|
+
import { SPEAK_SCRIPT } from "./speak-script.js"
|
|
7
7
|
|
|
8
8
|
const POWERSHELL = "powershell.exe"
|
|
9
9
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const MAX_CHUNK = 250
|
|
2
|
+
|
|
3
|
+
/** Turns Markdown into something worth hearing. */
|
|
4
|
+
export function cleanForSpeech(text: string, skipCode: boolean): string {
|
|
5
|
+
let value = text.replace(/\r\n/g, "\n")
|
|
6
|
+
|
|
7
|
+
if (skipCode) {
|
|
8
|
+
value = value.replace(/```[\s\S]*?```/g, " ")
|
|
9
|
+
} else {
|
|
10
|
+
value = value.replace(/```[^\n]*\n([\s\S]*?)```/g, "$1")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
value = value.replace(/`([^`]+)`/g, "$1")
|
|
14
|
+
value = value.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
|
15
|
+
value = value.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
16
|
+
value = value.replace(/^\s*#{1,6}\s+/gm, "")
|
|
17
|
+
value = value.replace(/^\s*>\s?/gm, "")
|
|
18
|
+
value = value.replace(/^\s*[-*+]\s+/gm, "")
|
|
19
|
+
value = value.replace(/^\s*\d+\.\s+/gm, "")
|
|
20
|
+
value = value.replace(/(\*\*|__)(.*?)\1/g, "$2")
|
|
21
|
+
value = value.replace(/(\*|_)(.*?)\1/g, "$2")
|
|
22
|
+
value = value.replace(/^\s*\|.*\|\s*$/gm, " ")
|
|
23
|
+
value = value.replace(/\n{2,}/g, ". ")
|
|
24
|
+
value = value.replace(/\n/g, " ")
|
|
25
|
+
value = value.replace(/\s+/g, " ")
|
|
26
|
+
value = value.replace(/\s+([.,;:!?])/g, "$1")
|
|
27
|
+
value = value.replace(/([.,;:!?]){2,}/g, "$1")
|
|
28
|
+
|
|
29
|
+
return value.trim()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Splits at sentence boundaries, then at word boundaries for long sentences. */
|
|
33
|
+
export function chunkForSpeech(text: string, max = MAX_CHUNK): string[] {
|
|
34
|
+
const trimmed = text.trim()
|
|
35
|
+
if (!trimmed) return []
|
|
36
|
+
|
|
37
|
+
const sentences = trimmed.split(/(?<=[.!?…])\s+/)
|
|
38
|
+
const chunks: string[] = []
|
|
39
|
+
let current = ""
|
|
40
|
+
|
|
41
|
+
const pushWords = (sentence: string) => {
|
|
42
|
+
let part = ""
|
|
43
|
+
for (const word of sentence.split(/\s+/)) {
|
|
44
|
+
if (!word) continue
|
|
45
|
+
if (part && part.length + 1 + word.length > max) {
|
|
46
|
+
chunks.push(part)
|
|
47
|
+
part = word
|
|
48
|
+
} else {
|
|
49
|
+
part = part ? `${part} ${word}` : word
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return part
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const sentence of sentences) {
|
|
56
|
+
if (sentence.length > max) {
|
|
57
|
+
if (current) {
|
|
58
|
+
chunks.push(current)
|
|
59
|
+
current = ""
|
|
60
|
+
}
|
|
61
|
+
current = pushWords(sentence)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (current && current.length + 1 + sentence.length > max) {
|
|
66
|
+
chunks.push(current)
|
|
67
|
+
current = sentence
|
|
68
|
+
} else {
|
|
69
|
+
current = current ? `${current} ${sentence}` : sentence
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (current) chunks.push(current)
|
|
74
|
+
return chunks
|
|
75
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
+
import { 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
|
+
|
|
7
|
+
const POWERSHELL = "powershell.exe"
|
|
8
|
+
|
|
9
|
+
const PLAYER_SCRIPT = `param(
|
|
10
|
+
[Parameter(Mandatory = $true)][string]$Path
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
$ErrorActionPreference = "Stop"
|
|
14
|
+
$player = $null
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
[Windows.Media.Playback.MediaPlayer, Windows.Media.Playback, ContentType = WindowsRuntime] | Out-Null
|
|
18
|
+
[Windows.Media.Core.MediaSource, Windows.Media.Core, ContentType = WindowsRuntime] | Out-Null
|
|
19
|
+
|
|
20
|
+
$player = [Windows.Media.Playback.MediaPlayer]::new()
|
|
21
|
+
$player.Source = [Windows.Media.Core.MediaSource]::CreateFromUri([Uri]$Path)
|
|
22
|
+
$player.Play()
|
|
23
|
+
|
|
24
|
+
$deadline = [DateTime]::UtcNow.AddSeconds(5)
|
|
25
|
+
$duration = 0
|
|
26
|
+
do {
|
|
27
|
+
Start-Sleep -Milliseconds 50
|
|
28
|
+
$duration = $player.PlaybackSession.NaturalDuration.TotalMilliseconds
|
|
29
|
+
} while ($duration -le 0 -and [DateTime]::UtcNow -lt $deadline)
|
|
30
|
+
|
|
31
|
+
if ($duration -le 0) {
|
|
32
|
+
$player.Dispose()
|
|
33
|
+
exit 5
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Start-Sleep -Milliseconds ([int]$duration + 350)
|
|
37
|
+
$player.Pause()
|
|
38
|
+
$player.Dispose()
|
|
39
|
+
exit 0
|
|
40
|
+
} catch {
|
|
41
|
+
if ($null -ne $player) {
|
|
42
|
+
try { $player.Dispose() } catch { }
|
|
43
|
+
}
|
|
44
|
+
Write-Error $_
|
|
45
|
+
exit 4
|
|
46
|
+
}
|
|
47
|
+
`
|
|
48
|
+
|
|
49
|
+
export interface Playback {
|
|
50
|
+
stop(): void
|
|
51
|
+
done: Promise<boolean>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function workDir(): string {
|
|
55
|
+
const dir = join(tmpdir(), "opencode-voice-dictation")
|
|
56
|
+
mkdirSync(dir, { recursive: true })
|
|
57
|
+
return dir
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function killTree(child: ChildProcess) {
|
|
61
|
+
const pid = child.pid
|
|
62
|
+
if (pid === undefined) {
|
|
63
|
+
child.kill()
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
stdio: "ignore",
|
|
69
|
+
})
|
|
70
|
+
killer.on("error", () => child.kill())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function playMp3Base64(base64: string): Playback {
|
|
74
|
+
if (process.platform !== "win32") {
|
|
75
|
+
return { stop() {}, done: Promise.resolve(false) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const id = randomUUID()
|
|
79
|
+
const file = join(workDir(), `speech-${id}.mp3`)
|
|
80
|
+
const script = join(workDir(), `speech-${id}.ps1`)
|
|
81
|
+
writeFileSync(file, Buffer.from(base64, "base64"))
|
|
82
|
+
writeFileSync(script, PLAYER_SCRIPT, "utf8")
|
|
83
|
+
|
|
84
|
+
let child: ChildProcess | undefined
|
|
85
|
+
let settled = false
|
|
86
|
+
let resolveDone!: (ok: boolean) => void
|
|
87
|
+
const done = new Promise<boolean>((resolve) => {
|
|
88
|
+
resolveDone = resolve
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const clean = () => {
|
|
92
|
+
for (const path of [file, script]) {
|
|
93
|
+
try {
|
|
94
|
+
rmSync(path, { force: true })
|
|
95
|
+
} catch {
|
|
96
|
+
// Best effort: the temp folder is cleaned by the OS anyway.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const finish = (ok: boolean) => {
|
|
102
|
+
if (settled) return
|
|
103
|
+
settled = true
|
|
104
|
+
clean()
|
|
105
|
+
resolveDone(ok)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
child = spawn(
|
|
109
|
+
POWERSHELL,
|
|
110
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-Path", file],
|
|
111
|
+
{
|
|
112
|
+
windowsHide: true,
|
|
113
|
+
stdio: "ignore",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
child.on("error", () => finish(false))
|
|
118
|
+
child.on("close", (code) => finish(code === 0))
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
stop() {
|
|
122
|
+
if (settled) return
|
|
123
|
+
if (child) killTree(child)
|
|
124
|
+
finish(false)
|
|
125
|
+
},
|
|
126
|
+
done,
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/tui.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
2
|
+
import { VoiceRpc } from "./rpc.js"
|
|
3
|
+
import { chunkForSpeech, cleanForSpeech } from "./speech-text.js"
|
|
4
|
+
import { speakSapi, type SpeakHandle } from "./sapi-speech.js"
|
|
5
|
+
import { playMp3Base64, type Playback } from "./tui/player.js"
|
|
6
|
+
import { sendVoiceTypingShortcut } from "./win-h.js"
|
|
7
|
+
|
|
8
|
+
const DEFAULT_VOICE = "es-ES-XimenaNeural"
|
|
9
|
+
const DEFAULT_RATE = 1
|
|
10
|
+
const MAX_EDGE_CHUNK = 4000
|
|
11
|
+
|
|
12
|
+
function errorMessage(error: unknown): string {
|
|
13
|
+
if (error instanceof Error && error.message) return error.message
|
|
14
|
+
if (typeof error === "string" && error) return error
|
|
15
|
+
return "error desconocido"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default Plugin.define({
|
|
19
|
+
id: "voice-dictation.tui",
|
|
20
|
+
setup(context) {
|
|
21
|
+
const voice = typeof context.options.voice === "string" ? context.options.voice : DEFAULT_VOICE
|
|
22
|
+
const rate = typeof context.options.rate === "number" ? context.options.rate : DEFAULT_RATE
|
|
23
|
+
const skipCode = typeof context.options.skipCode === "boolean" ? context.options.skipCode : true
|
|
24
|
+
|
|
25
|
+
let generation = 0
|
|
26
|
+
let busy = false
|
|
27
|
+
let active: { stop(): void } | undefined
|
|
28
|
+
|
|
29
|
+
const stop = () => {
|
|
30
|
+
generation++
|
|
31
|
+
active?.stop()
|
|
32
|
+
active = undefined
|
|
33
|
+
busy = false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dictate = async () => {
|
|
37
|
+
if (process.platform !== "win32") {
|
|
38
|
+
context.ui.toast.show({
|
|
39
|
+
title: "Dictado por voz",
|
|
40
|
+
message: "El dictado de la TUI está disponible solo en Windows por ahora.",
|
|
41
|
+
variant: "warning",
|
|
42
|
+
})
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await sendVoiceTypingShortcut()
|
|
48
|
+
context.ui.toast.show({
|
|
49
|
+
title: "Dictado por voz",
|
|
50
|
+
message: "Dictado de Windows abierto. Habla y el texto aparecerá en el prompt.",
|
|
51
|
+
variant: "info",
|
|
52
|
+
})
|
|
53
|
+
} catch (error) {
|
|
54
|
+
context.ui.toast.show({
|
|
55
|
+
title: "Dictado por voz",
|
|
56
|
+
message: `No se pudo abrir el dictado: ${errorMessage(error)}`,
|
|
57
|
+
variant: "error",
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const speakSystem = (text: string, current: number) => {
|
|
63
|
+
return new Promise<void>((resolve) => {
|
|
64
|
+
let settled = false
|
|
65
|
+
let wrapper: { stop(): void } | undefined
|
|
66
|
+
|
|
67
|
+
const finish = () => {
|
|
68
|
+
if (settled) return
|
|
69
|
+
settled = true
|
|
70
|
+
if (active === wrapper) active = undefined
|
|
71
|
+
resolve()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const handle: SpeakHandle = speakSapi({ text, rate, voice: "" }, (ok) => {
|
|
75
|
+
if (!ok && current === generation) {
|
|
76
|
+
context.ui.toast.show({
|
|
77
|
+
title: "Dictado por voz",
|
|
78
|
+
message: "Tampoco se pudo reproducir con la voz del sistema.",
|
|
79
|
+
variant: "error",
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
finish()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
wrapper = {
|
|
86
|
+
stop() {
|
|
87
|
+
handle.stop()
|
|
88
|
+
finish()
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
active = wrapper
|
|
92
|
+
|
|
93
|
+
if (current !== generation) {
|
|
94
|
+
handle.stop()
|
|
95
|
+
finish()
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const read = async () => {
|
|
101
|
+
if (busy || active) {
|
|
102
|
+
stop()
|
|
103
|
+
context.ui.toast.show({
|
|
104
|
+
title: "Dictado por voz",
|
|
105
|
+
message: "Lectura detenida.",
|
|
106
|
+
variant: "info",
|
|
107
|
+
})
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (process.platform !== "win32") {
|
|
112
|
+
context.ui.toast.show({
|
|
113
|
+
title: "Dictado por voz",
|
|
114
|
+
message: "La lectura de la TUI está disponible solo en Windows por ahora.",
|
|
115
|
+
variant: "warning",
|
|
116
|
+
})
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const route = context.ui.router.current()
|
|
121
|
+
if (route.type !== "session") {
|
|
122
|
+
context.ui.toast.show({
|
|
123
|
+
title: "Dictado por voz",
|
|
124
|
+
message: "Abre una sesión para leer su última respuesta.",
|
|
125
|
+
variant: "warning",
|
|
126
|
+
})
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const current = ++generation
|
|
131
|
+
busy = true
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const response = await context.client.message.list({
|
|
135
|
+
sessionID: route.sessionID,
|
|
136
|
+
type: "assistant",
|
|
137
|
+
order: "desc",
|
|
138
|
+
limit: 10,
|
|
139
|
+
})
|
|
140
|
+
if (current !== generation) return
|
|
141
|
+
|
|
142
|
+
const message = response.data.find(
|
|
143
|
+
(entry) =>
|
|
144
|
+
entry.type === "assistant" &&
|
|
145
|
+
entry.content.some((part) => part.type === "text" && part.text.trim().length > 0),
|
|
146
|
+
)
|
|
147
|
+
if (!message || message.type !== "assistant") {
|
|
148
|
+
context.ui.toast.show({
|
|
149
|
+
title: "Dictado por voz",
|
|
150
|
+
message: "No hay una respuesta para leer.",
|
|
151
|
+
variant: "info",
|
|
152
|
+
})
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const text = message.content
|
|
157
|
+
.filter((part) => part.type === "text")
|
|
158
|
+
.map((part) => part.text)
|
|
159
|
+
.join("\n")
|
|
160
|
+
const cleaned = cleanForSpeech(text, skipCode)
|
|
161
|
+
if (!cleaned) {
|
|
162
|
+
context.ui.toast.show({
|
|
163
|
+
title: "Dictado por voz",
|
|
164
|
+
message: "La última respuesta no tiene texto para leer.",
|
|
165
|
+
variant: "info",
|
|
166
|
+
})
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let edgeFailed = false
|
|
171
|
+
for (const chunk of chunkForSpeech(cleaned, MAX_EDGE_CHUNK)) {
|
|
172
|
+
if (current !== generation) return
|
|
173
|
+
|
|
174
|
+
let audio: string
|
|
175
|
+
try {
|
|
176
|
+
const result = await context.client.rpc(VoiceRpc).edgeSpeak({ text: chunk, voice, rate })
|
|
177
|
+
if (typeof result !== "string" || result.length === 0) {
|
|
178
|
+
edgeFailed = true
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
audio = result
|
|
182
|
+
} catch {
|
|
183
|
+
edgeFailed = true
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
if (current !== generation) return
|
|
187
|
+
|
|
188
|
+
const playback: Playback = playMp3Base64(audio)
|
|
189
|
+
active = playback
|
|
190
|
+
const ok = await playback.done
|
|
191
|
+
if (active === playback) active = undefined
|
|
192
|
+
if (current !== generation) return
|
|
193
|
+
|
|
194
|
+
if (!ok) {
|
|
195
|
+
edgeFailed = true
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!edgeFailed || current !== generation) return
|
|
201
|
+
|
|
202
|
+
context.ui.toast.show({
|
|
203
|
+
title: "Dictado por voz",
|
|
204
|
+
message: "No se pudo usar Edge. Usando la voz del sistema.",
|
|
205
|
+
variant: "warning",
|
|
206
|
+
})
|
|
207
|
+
await speakSystem(cleaned, current)
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (current === generation) {
|
|
210
|
+
context.ui.toast.show({
|
|
211
|
+
title: "Dictado por voz",
|
|
212
|
+
message: `No se pudo leer la respuesta: ${errorMessage(error)}`,
|
|
213
|
+
variant: "error",
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
if (current === generation) busy = false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
context.keymap.layer(() => ({
|
|
222
|
+
mode: "global",
|
|
223
|
+
priority: 10,
|
|
224
|
+
commands: [
|
|
225
|
+
{
|
|
226
|
+
id: "voice-dictation.tui.dictate",
|
|
227
|
+
title: "Dictar por voz",
|
|
228
|
+
description: "Abre el dictado de Windows (Win+H) en el prompt.",
|
|
229
|
+
group: "Voz",
|
|
230
|
+
bind: "<leader>d",
|
|
231
|
+
palette: true,
|
|
232
|
+
slash: { name: "dictado", aliases: ["dictate"] },
|
|
233
|
+
suggested: true,
|
|
234
|
+
run: dictate,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: "voice-dictation.tui.read",
|
|
238
|
+
title: "Leer última respuesta",
|
|
239
|
+
description: "Lee en voz alta la última respuesta con voces Edge.",
|
|
240
|
+
group: "Voz",
|
|
241
|
+
bind: "<leader>v",
|
|
242
|
+
palette: true,
|
|
243
|
+
slash: { name: "leer", aliases: ["read"] },
|
|
244
|
+
suggested: true,
|
|
245
|
+
run: read,
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
bindings: ["voice-dictation.tui.dictate", "voice-dictation.tui.read"],
|
|
249
|
+
}))
|
|
250
|
+
|
|
251
|
+
return () => stop()
|
|
252
|
+
},
|
|
253
|
+
})
|
|
File without changes
|
|
File without changes
|