opencode-voice-dictation 0.7.0 → 0.8.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 CHANGED
@@ -91,7 +91,13 @@ En Linux el flujo es distinto: la ventana graba con `getUserMedia` +
91
91
 
92
92
  **Linux (Ubuntu)**
93
93
 
94
- - OpenCode Desktop y `ffmpeg` instalados.
94
+ - OpenCode Desktop.
95
+ - **ffmpeg obligatorio**: convierte la grabación WebM a WAV. El plugin lo
96
+ detecta y ofrece **Instalar ffmpeg** en Ajustes; si no puede, muestra el
97
+ comando. Instalación manual:
98
+ ```bash
99
+ sudo apt install -y ffmpeg
100
+ ```
95
101
  - `glibc` compatible con el binario oficial de `whisper.cpp` (Ubuntu 22.04+).
96
102
  - Micrófono accesible por la app. El primer dictado descarga el binario
97
103
  (~10 MB) y el modelo (`base-q5_1`, 57 MB) desde Ajustes.
@@ -205,6 +211,7 @@ los propios ajustes. Los motores en la nube del roadmap solo pedirán una API ke
205
211
  | Comprobar WhisperX | — | Arranca el worker y valida GPU/modelo. |
206
212
  | Modelo (whisper.cpp) | `base-q5_1` | Solo Linux. `base-q5_1` (57 MB) o `small-q5_1` (180 MB, más preciso). |
207
213
  | Idioma (whisper.cpp) | Automático | Solo Linux. Autodetección o `es`, `en`, `pt`. |
214
+ | ffmpeg | — | Solo Linux. Indica si está instalado y ofrece **Instalar ffmpeg** con el diálogo de contraseña del sistema. |
208
215
  | Descargar motor | — | Solo Linux. Baja el binario oficial y el modelo a `~/.local/share/opencode-voice-dictation`. |
209
216
  | Comprobar whisper.cpp | — | Solo Linux. Indica si el servidor y el modelo están instalados. |
210
217
 
@@ -255,7 +262,9 @@ bloqueas el micrófono, verás el aviso «No hay ningún micrófono disponible»
255
262
 
256
263
  En **Linux**, la app abre el micrófono con `getUserMedia`; el navegador/Electron
257
264
  puede pedir permiso la primera vez. El servidor solo ejecuta `ffmpeg` y el
258
- binario descargado.
265
+ binario descargado. Si falta `ffmpeg` y pulsas **Instalar ffmpeg**, se ejecuta
266
+ `pkexec`/`apt-get` con el diálogo de contraseña del sistema; el plugin nunca
267
+ guarda esa contraseña.
259
268
 
260
269
  ## Solución de problemas
261
270
 
@@ -275,6 +284,7 @@ binario descargado.
275
284
  | «Abre una conversación para leer su último mensaje» | En Home o en un chat nuevo sin mensajes no hay respuesta que leer. |
276
285
  | WhisperX se queda en «Transcribiendo…» | El worker no arrancó. Mira el toast: ruta del Python, CUDA o token de Hugging Face. Guía: <https://github.com/evious/opencode-voice-dictation/blob/main/docs/whisperx.md>. |
277
286
  | whisper.cpp dice «not installed» | Entra en Ajustes → Dictado por voz → **Descargar motor**. Requiere internet la primera vez. |
287
+ | Falta ffmpeg o falla la conversión del audio | Ajustes → Dictado por voz → whisper.cpp → **Instalar ffmpeg**, o ejecuta `sudo apt install -y ffmpeg`. El motor no puede transcribir sin ffmpeg. |
278
288
  | whisper.cpp falla al arrancar | Comprueba que `ffmpeg` está en el `PATH` (`ffmpeg -version`) y que el binario es compatible con tu `glibc` (Ubuntu 22.04+). |
279
289
  | whisper.cpp tarda mucho | Usa `base-q5_1` en portátiles modestos; `small-q5_1` necesita más CPU y memoria. La primera transcripción carga el modelo. |
280
290
  | El RPC devuelve 400 o no cargan las voces de Edge | El plugin server no cargó: Ajustes → Extensiones → Plugins → **Reload**, o reinicia el servicio. |
@@ -368,6 +378,7 @@ src/
368
378
  ├── definition.ts os, stores, i18n es/en
369
379
  ├── contract.ts Ipc: dictado y lectura
370
380
  ├── window.tsx Píldoras, comandos e inserción de texto
381
+ ├── errors.ts Extrae el mensaje real de los errores RPC
371
382
  ├── recorder.ts getUserMedia + MediaRecorder para WhisperX/whisper.cpp
372
383
  ├── main.ts Dictado Win+H / System.Speech y voz SAPI
373
384
  ├── speak.ts Limpieza de Markdown, troceado, Edge y speechSynthesis
@@ -1,4 +1,4 @@
1
- "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const E=require("@opencode/plugin/gui"),p=require("effect"),ee=require("node:child_process"),re=require("node:crypto"),M=require("node:fs"),ne=require("node:os"),G=require("node:path"),a=require("solid-js/web"),S=require("solid-js"),ke=E.Plugin.define({os:["windows","linux"],stores:{prefs:E.Store.global(p.Schema.Struct({locale:p.Schema.String,minConfidence:p.Schema.Number,trailingSpace:p.Schema.Boolean,autoFocus:p.Schema.Boolean}),{locale:"es-ES",minConfidence:.4,trailingSpace:!0,autoFocus:!0}),speech:E.Store.global(p.Schema.Struct({voiceURI:p.Schema.String,rate:p.Schema.Number,skipCode:p.Schema.Boolean}),{voiceURI:"",rate:1,skipCode:!0}),engine:E.Store.global(p.Schema.Struct({mode:p.Schema.String}),{mode:"windows"}),tts:E.Store.global(p.Schema.Struct({engine:p.Schema.String,voice:p.Schema.String}),{engine:"edge",voice:"es-ES-XimenaNeural"}),whisper:E.Store.global(p.Schema.Struct({python:p.Schema.String,model:p.Schema.String,language:p.Schema.String}),{python:"~/peopleby/voice_to_text/api_voice/demo/whisperx-env/bin/python",model:"large-v3-turbo",language:""}),whispercpp:E.Store.global(p.Schema.Struct({model:p.Schema.String,language:p.Schema.String}),{model:"base-q5_1",language:"auto"})},i18n:{en:{"pill.idle":"Dictate","pill.starting":"Starting…","pill.listening":"Listening…","pill.dictating":"Dictating…","pill.error":"Retry dictation","pill.title":"Voice dictation","pill.winH":"Open Windows voice typing (Win+H)","pill.stop":"Stop dictation","command.toggle":"Toggle voice dictation","command.toggle.description":"Dictate into the prompt with the selected engine","command.configure":"Voice dictation settings","error.title":"Voice dictation","error.language":"The speech language {locale} is not installed. Available: {available}.","error.none":"Windows has no speech recognizer installed. Settings › Time & language › Language › Speech.","error.mic":"No microphone is available for speech recognition.","error.system":"System.Speech is not available in this Windows installation.","error.exit":"The dictation engine stopped unexpectedly.","error.unknown":"Dictation failed ({code}).","toast.discarded":"Dictation discarded: the active session changed.","settings.title":"Voice dictation","settings.description":"Offline dictation on Windows and Linux. The text is inserted into the prompt; it is never sent automatically.","settings.section":"Dictation","settings.engine":"Dictation engine","settings.engine.description":"Windows offers Win+H and System.Speech; Linux uses whisper.cpp locally.","engine.windows":"Windows Voice Typing (Win+H)","engine.system":"System.Speech (offline)","engine.whisper":"WhisperX local (WSL)","engine.whispercpp":"whisper.cpp (local, native)","pill.recording":"Recording…","pill.transcribing":"Transcribing…","pill.whisper":"Record and transcribe with WhisperX","error.whisper":"WhisperX could not transcribe the audio.","whisper.section":"WhisperX (local)","whisper.python":"Environment Python","whisper.python.description":"Python of the venv that has whisperx installed.","whisper.model":"Model","whisper.model.description":"large-v3-turbo is the fastest with good accuracy on a GPU.","whisper.language":"Language","whisper.language.description":"Automatic detects the language of each recording.","whisper.language.auto":"Automatic","whisper.test":"Check WhisperX","whisper.test.description":"Starts the worker and reports whether the model loads.","whisper.test.run":"Check","whisper.test.ready":"WhisperX ready: {model} ({device})","whisper.test.error":"WhisperX is not ready: {error}","whisper.empty":"WhisperX returned no text.","pill.whispercpp":"Record and transcribe with whisper.cpp","error.whispercpp":"whisper.cpp could not transcribe the audio.","whispercpp.section":"whisper.cpp (local, native)","whispercpp.description":"Runs on the CPU without WSL or Python. The first time you need to download the server (~10 MB) and the model.","whispercpp.model":"Model","whispercpp.model.description":"base-q5_1 is the lightest model (57 MB) and works on modest laptops. small-q5_1 is more accurate but uses more CPU and memory.","whispercpp.language":"Language","whispercpp.language.description":"Automatic detects the language of each recording.","whispercpp.language.auto":"Automatic","whispercpp.install":"Download engine","whispercpp.install.description":"Downloads the official whisper.cpp server and the selected model to ~/.local/share/opencode-voice-dictation. Needs internet once.","whispercpp.install.run":"Download","whispercpp.install.busy":"Downloading…","whispercpp.install.ok":"whisper.cpp installed: {model}","whispercpp.install.error":"Could not install whisper.cpp: {error}","whispercpp.test":"Check whisper.cpp","whispercpp.test.description":"Reports whether the server and the selected model are installed.","whispercpp.test.run":"Check","whispercpp.test.ready":"whisper.cpp ready: {model}","whispercpp.test.missing":"whisper.cpp is not installed. Download the engine first.","whispercpp.test.error":"whisper.cpp is not ready: {error}","whispercpp.empty":"whisper.cpp returned no text.","whispercpp.linux":"whisper.cpp is only available on Linux. On Windows use Win+H, System.Speech or WhisperX.","settings.winh":"Test Win+H","settings.winh.description":"Sends the Windows voice typing shortcut. If the floating bar appears, it works.","settings.winh.run":"Test Win+H","settings.winh.sent":"Win+H sent. If the Windows dictation bar did not appear, check Settings › Accessibility › Voice typing.","error.winh":"Windows voice typing could not be triggered.","settings.language":"Language","settings.language.description":"A speech recognizer installed in Windows. Accents and punctuation depend on it.","settings.confidence":"Minimum confidence","settings.confidence.description":"Results below this threshold are ignored to avoid noise.","confidence.low":"Low (0.2)","confidence.normal":"Normal (0.4)","confidence.high":"High (0.6)","settings.space":"Add a space before the text","settings.space.description":"Separates the dictation from what is already in the prompt.","settings.focus":"Focus the prompt after dictating","settings.focus.description":"Moves the cursor to the prompt so you can review or send it.","settings.test.section":"Diagnostics","settings.test":"Test microphone","settings.test.description":"Checks installed recognizers and the default microphone.","settings.test.run":"Run test","settings.test.success":"Speech recognition ready: {locale}","settings.test.failed":"Not ready: {reason}","common.reason.no-recognizers":"no speech language installed","common.reason.no-microphone":"no microphone available","read.idle":"Read","read.speaking":"Reading…","read.paused":"Paused","read.pause":"Pause","read.resume":"Resume","read.restart":"Read again","read.stop":"Stop reading","read.title":"Read the last message aloud","command.read":"Read the last message aloud","command.read.description":"Speaks the last reply with a system voice","read.noSession":"Open a conversation to read its last message.","read.empty":"The last message has no text to read.","read.error":"The last message could not be read.","read.noVoices":"The system has no voices installed for reading.","speech.section":"Read aloud","speech.engine":"Voice engine","speech.engine.description":"Edge neural sounds much more natural but needs internet. System uses the installed Windows voices, offline.","speech.engine.edge":"Edge neural (online)","speech.engine.system":"System (offline)","error.edge":"The Edge voice could not be used. Check the connection or switch to the system voice.","speech.edge.error":"Edge voices could not be loaded","speech.voice":"Voice","speech.voice.description":"The voice used for reading.","speech.voice.auto":"Automatic","speech.rate":"Speed","speech.rate.description":"How fast the text is spoken.","speech.rate.slow":"0.8×","speech.rate.normal":"1×","speech.rate.fast":"1.2×","speech.rate.faster":"1.5×","speech.skipCode":"Skip code blocks","speech.skipCode.description":"Read only the prose; code, paths and commands are left out.","speech.preview":"Test voice","speech.preview.description":"Speaks a short sample with the current voice and speed.","speech.preview.text":"Hello, this is a reading test."},es:{"pill.idle":"Dictar","pill.starting":"Iniciando…","pill.listening":"Escuchando…","pill.dictating":"Dictando…","pill.error":"Reintentar dictado","pill.title":"Dictado por voz","pill.winH":"Abrir el dictado de Windows (Win+H)","pill.stop":"Detener dictado","command.toggle":"Alternar dictado por voz","command.toggle.description":"Dicta en el prompt con el motor elegido","command.configure":"Ajustes de dictado por voz","error.title":"Dictado por voz","error.language":"El idioma de reconocimiento {locale} no está instalado. Disponibles: {available}.","error.none":"Windows no tiene ningún reconocedor de voz instalado. Configuración › Hora e idioma › Idioma › Voz.","error.mic":"No hay ningún micrófono disponible para el reconocimiento de voz.","error.system":"System.Speech no está disponible en esta instalación de Windows.","error.exit":"El motor de dictado se detuvo inesperadamente.","error.unknown":"El dictado falló ({code}).","toast.discarded":"Dictado descartado: la sesión activa cambió.","settings.title":"Dictado por voz","settings.description":"Dictado offline en Windows y Linux. El texto se inserta en el prompt; nunca se envía solo.","settings.section":"Dictado","settings.engine":"Motor de dictado","settings.engine.description":"Windows ofrece Win+H y System.Speech; Linux usa whisper.cpp en local.","engine.windows":"Windows Voice Typing (Win+H)","engine.system":"System.Speech (offline)","engine.whisper":"WhisperX local (WSL)","engine.whispercpp":"whisper.cpp (local, nativo)","pill.recording":"Grabando…","pill.transcribing":"Transcribiendo…","pill.whisper":"Grabar y transcribir con WhisperX","error.whisper":"WhisperX no pudo transcribir el audio.","whisper.section":"WhisperX (local)","whisper.python":"Python del entorno","whisper.python.description":"Python del venv que tiene whisperx instalado.","whisper.model":"Modelo","whisper.model.description":"large-v3-turbo es el más rápido con buena precisión en GPU.","whisper.language":"Idioma","whisper.language.description":"Automático detecta el idioma de cada grabación.","whisper.language.auto":"Automático","whisper.test":"Comprobar WhisperX","whisper.test.description":"Arranca el worker e indica si el modelo carga.","whisper.test.run":"Comprobar","whisper.test.ready":"WhisperX listo: {model} ({device})","whisper.test.error":"WhisperX no está listo: {error}","whisper.empty":"WhisperX no devolvió texto.","pill.whispercpp":"Grabar y transcribir con whisper.cpp","error.whispercpp":"whisper.cpp no pudo transcribir el audio.","whispercpp.section":"whisper.cpp (local, nativo)","whispercpp.description":"Funciona en CPU sin WSL ni Python. La primera vez hay que descargar el servidor (~10 MB) y el modelo.","whispercpp.model":"Modelo","whispercpp.model.description":"base-q5_1 es el más ligero (57 MB) y va bien en portátiles modestos. small-q5_1 es más preciso pero consume más CPU y memoria.","whispercpp.language":"Idioma","whispercpp.language.description":"Automático detecta el idioma de cada grabación.","whispercpp.language.auto":"Automático","whispercpp.install":"Descargar motor","whispercpp.install.description":"Descarga el servidor oficial de whisper.cpp y el modelo elegido en ~/.local/share/opencode-voice-dictation. Solo necesita internet una vez.","whispercpp.install.run":"Descargar","whispercpp.install.busy":"Descargando…","whispercpp.install.ok":"whisper.cpp instalado: {model}","whispercpp.install.error":"No se pudo instalar whisper.cpp: {error}","whispercpp.test":"Comprobar whisper.cpp","whispercpp.test.description":"Indica si el servidor y el modelo elegido están instalados.","whispercpp.test.run":"Comprobar","whispercpp.test.ready":"whisper.cpp listo: {model}","whispercpp.test.missing":"whisper.cpp no está instalado. Descarga el motor primero.","whispercpp.test.error":"whisper.cpp no está listo: {error}","whispercpp.empty":"whisper.cpp no devolvió texto.","whispercpp.linux":"whisper.cpp solo está disponible en Linux. En Windows usa Win+H, System.Speech o WhisperX.","settings.winh":"Probar Win+H","settings.winh.description":"Envía el atajo del dictado de Windows. Si aparece la barra flotante, funciona.","settings.winh.run":"Probar Win+H","settings.winh.sent":"Win+H enviado. Si no apareció la barra, revisa Configuración › Accesibilidad › Dictado por voz.","settings.language":"Idioma","settings.language.description":"Un reconocedor instalado en Windows. Los acentos y la puntuación dependen de él.","settings.confidence":"Confianza mínima","settings.confidence.description":"Los resultados por debajo de este umbral se ignoran para evitar ruido.","confidence.low":"Baja (0,2)","confidence.normal":"Normal (0,4)","confidence.high":"Alta (0,6)","settings.space":"Añadir un espacio antes del texto","settings.space.description":"Separa el dictado de lo que ya hay en el prompt.","settings.focus":"Enfocar el prompt al terminar","settings.focus.description":"Mueve el cursor al prompt para revisar o enviar el texto.","settings.test.section":"Diagnóstico","settings.test":"Probar micrófono","settings.test.description":"Comprueba los reconocedores instalados y el micrófono predeterminado.","settings.test.run":"Ejecutar prueba","settings.test.success":"Reconocimiento de voz listo: {locale}","settings.test.failed":"No está listo: {reason}","common.reason.no-recognizers":"no hay ningún idioma de voz instalado","common.reason.no-microphone":"no hay micrófono disponible","read.idle":"Leer","read.speaking":"Leyendo…","read.paused":"Pausado","read.pause":"Pausar","read.resume":"Reanudar","read.restart":"Leer de nuevo","read.stop":"Detener lectura","read.title":"Leer en voz alta el último mensaje","command.read":"Leer en voz alta el último mensaje","command.read.description":"Reproduce la última respuesta con una voz del sistema","read.noSession":"Abre una conversación para leer su último mensaje.","read.empty":"El último mensaje no tiene texto para leer.","read.error":"No se pudo leer el último mensaje.","read.noVoices":"El sistema no tiene voces instaladas para la lectura.","speech.section":"Lectura en voz alta","speech.engine":"Motor de voz","speech.engine.description":"Edge neuronal suena mucho más natural pero necesita internet. Sistema usa las voces instaladas del equipo, sin conexión.","speech.engine.edge":"Edge neuronal (online)","speech.engine.system":"Sistema (offline)","error.edge":"No se pudo usar la voz de Edge. Revisa la conexión o cambia a la voz del sistema.","speech.edge.error":"No se pudieron cargar las voces de Edge","speech.voice":"Voz","speech.voice.description":"La voz que se usa para leer.","speech.voice.auto":"Automática","speech.rate":"Velocidad","speech.rate.description":"A qué ritmo se lee el texto.","speech.rate.slow":"0,8×","speech.rate.normal":"1×","speech.rate.fast":"1,2×","speech.rate.faster":"1,5×","speech.skipCode":"Ignorar bloques de código","speech.skipCode.description":"Lee solo la prosa; omite código, rutas y comandos.","speech.preview":"Probar voz","speech.preview.description":"Lee una frase corta con la voz y velocidad actuales.","speech.preview.text":"Hola, esta es una prueba de lectura."}}}),oe=E.Ipc.define({id:"voice-dictation.dictation",state:p.Schema.Struct({status:p.Schema.Literals(["idle","starting","listening","error"]),error:p.Schema.String,locale:p.Schema.String}),methods:{start:{input:p.Schema.Struct({locale:p.Schema.String,minConfidence:p.Schema.Number})},stop:{},diagnose:{output:p.Schema.Struct({locales:p.Schema.Array(p.Schema.String),microphone:p.Schema.Boolean,selected:p.Schema.String,error:p.Schema.String})}},events:{final:p.Schema.Struct({text:p.Schema.String,confidence:p.Schema.Number}),error:p.Schema.Struct({code:p.Schema.String,detail:p.Schema.String,available:p.Schema.Array(p.Schema.String)})}}),he=E.Ipc.define({id:"voice-dictation.speech",methods:{speak:{input:p.Schema.Struct({text:p.Schema.String,rate:p.Schema.Number,voice:p.Schema.String})},stop:{},voices:{output:p.Schema.Array(p.Schema.String)}},events:{done:p.Schema.Struct({ok:p.Schema.Boolean})}}),se=E.Ipc.define({id:"voice-dictation.voice-typing",methods:{toggle:{}}}),pe=String.raw`param(
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const $=require("@opencode/plugin/gui"),p=require("effect"),ie=require("node:child_process"),oe=require("node:crypto"),K=require("node:fs"),ae=require("node:os"),Q=require("node:path"),o=require("solid-js/web"),y=require("solid-js"),$e=$.Plugin.define({os:["windows","linux"],stores:{prefs:$.Store.global(p.Schema.Struct({locale:p.Schema.String,minConfidence:p.Schema.Number,trailingSpace:p.Schema.Boolean,autoFocus:p.Schema.Boolean}),{locale:"es-ES",minConfidence:.4,trailingSpace:!0,autoFocus:!0}),speech:$.Store.global(p.Schema.Struct({voiceURI:p.Schema.String,rate:p.Schema.Number,skipCode:p.Schema.Boolean}),{voiceURI:"",rate:1,skipCode:!0}),engine:$.Store.global(p.Schema.Struct({mode:p.Schema.String}),{mode:"windows"}),tts:$.Store.global(p.Schema.Struct({engine:p.Schema.String,voice:p.Schema.String}),{engine:"edge",voice:"es-ES-XimenaNeural"}),whisper:$.Store.global(p.Schema.Struct({python:p.Schema.String,model:p.Schema.String,language:p.Schema.String}),{python:"~/peopleby/voice_to_text/api_voice/demo/whisperx-env/bin/python",model:"large-v3-turbo",language:""}),whispercpp:$.Store.global(p.Schema.Struct({model:p.Schema.String,language:p.Schema.String}),{model:"base-q5_1",language:"auto"})},i18n:{en:{"pill.idle":"Dictate","pill.starting":"Starting…","pill.listening":"Listening…","pill.dictating":"Dictating…","pill.error":"Retry dictation","pill.title":"Voice dictation","pill.winH":"Open Windows voice typing (Win+H)","pill.stop":"Stop dictation","command.toggle":"Toggle voice dictation","command.toggle.description":"Dictate into the prompt with the selected engine","command.configure":"Voice dictation settings","error.title":"Voice dictation","error.language":"The speech language {locale} is not installed. Available: {available}.","error.none":"Windows has no speech recognizer installed. Settings › Time & language › Language › Speech.","error.mic":"No microphone is available for speech recognition.","error.system":"System.Speech is not available in this Windows installation.","error.exit":"The dictation engine stopped unexpectedly.","error.unknown":"Dictation failed ({code}).","toast.discarded":"Dictation discarded: the active session changed.","settings.title":"Voice dictation","settings.description":"Offline dictation on Windows and Linux. The text is inserted into the prompt; it is never sent automatically.","settings.section":"Dictation","settings.engine":"Dictation engine","settings.engine.description":"Windows offers Win+H and System.Speech; Linux uses whisper.cpp locally.","engine.windows":"Windows Voice Typing (Win+H)","engine.system":"System.Speech (offline)","engine.whisper":"WhisperX local (WSL)","engine.whispercpp":"whisper.cpp (local, native)","pill.recording":"Recording…","pill.transcribing":"Transcribing…","pill.whisper":"Record and transcribe with WhisperX","error.whisper":"WhisperX could not transcribe the audio.","whisper.section":"WhisperX (local)","whisper.python":"Environment Python","whisper.python.description":"Python of the venv that has whisperx installed.","whisper.model":"Model","whisper.model.description":"large-v3-turbo is the fastest with good accuracy on a GPU.","whisper.language":"Language","whisper.language.description":"Automatic detects the language of each recording.","whisper.language.auto":"Automatic","whisper.test":"Check WhisperX","whisper.test.description":"Starts the worker and reports whether the model loads.","whisper.test.run":"Check","whisper.test.ready":"WhisperX ready: {model} ({device})","whisper.test.error":"WhisperX is not ready: {error}","whisper.empty":"WhisperX returned no text.","pill.whispercpp":"Record and transcribe with whisper.cpp","error.whispercpp":"whisper.cpp could not transcribe the audio.","whispercpp.section":"whisper.cpp (local, native)","whispercpp.description":"Runs on the CPU without WSL or Python. The first time you need to download the server (~10 MB) and the model.","whispercpp.model":"Model","whispercpp.model.description":"base-q5_1 is the lightest model (57 MB) and works on modest laptops. small-q5_1 is more accurate but uses more CPU and memory.","whispercpp.language":"Language","whispercpp.language.description":"Automatic detects the language of each recording.","whispercpp.language.auto":"Automatic","whispercpp.install":"Download engine","whispercpp.install.description":"Downloads the official whisper.cpp server and the selected model to ~/.local/share/opencode-voice-dictation. Needs internet once.","whispercpp.install.run":"Download","whispercpp.install.busy":"Downloading…","whispercpp.install.ok":"whisper.cpp installed: {model}","whispercpp.install.error":"Could not install whisper.cpp: {error}","whispercpp.test":"Check whisper.cpp","whispercpp.test.description":"Reports whether the server, the selected model and ffmpeg are ready.","whispercpp.test.run":"Check","whispercpp.test.ready":"whisper.cpp ready: {model}","whispercpp.test.missing":"whisper.cpp is not installed. Download the engine first.","whispercpp.test.error":"whisper.cpp is not ready: {error}","whispercpp.empty":"whisper.cpp returned no text.","whispercpp.ffmpeg":"ffmpeg","whispercpp.ffmpeg.description":"Required to convert the WebM recording to 16 kHz WAV. The plugin can install it with your system password.","whispercpp.ffmpeg.ready":"ffmpeg ready: {version}","whispercpp.ffmpeg.missing":"ffmpeg is not installed. Without it the audio cannot be transcribed.","whispercpp.ffmpeg.install":"Install ffmpeg","whispercpp.ffmpeg.install.busy":"Installing…","whispercpp.ffmpeg.install.ok":"ffmpeg installed.","whispercpp.ffmpeg.install.error":"Could not install ffmpeg: {error}","whispercpp.ffmpeg.install.manual":"Run it manually: {command}","error.ffmpeg":"ffmpeg is required to convert the audio but is not installed. Open Settings → Voice dictation → whisper.cpp and install it, or run: sudo apt install -y ffmpeg","whispercpp.linux":"whisper.cpp is only available on Linux. On Windows use Win+H, System.Speech or WhisperX.","settings.winh":"Test Win+H","settings.winh.description":"Sends the Windows voice typing shortcut. If the floating bar appears, it works.","settings.winh.run":"Test Win+H","settings.winh.sent":"Win+H sent. If the Windows dictation bar did not appear, check Settings › Accessibility › Voice typing.","error.winh":"Windows voice typing could not be triggered.","settings.language":"Language","settings.language.description":"A speech recognizer installed in Windows. Accents and punctuation depend on it.","settings.confidence":"Minimum confidence","settings.confidence.description":"Results below this threshold are ignored to avoid noise.","confidence.low":"Low (0.2)","confidence.normal":"Normal (0.4)","confidence.high":"High (0.6)","settings.space":"Add a space before the text","settings.space.description":"Separates the dictation from what is already in the prompt.","settings.focus":"Focus the prompt after dictating","settings.focus.description":"Moves the cursor to the prompt so you can review or send it.","settings.test.section":"Diagnostics","settings.test":"Test microphone","settings.test.description":"Checks installed recognizers and the default microphone.","settings.test.run":"Run test","settings.test.success":"Speech recognition ready: {locale}","settings.test.failed":"Not ready: {reason}","common.reason.no-recognizers":"no speech language installed","common.reason.no-microphone":"no microphone available","read.idle":"Read","read.speaking":"Reading…","read.paused":"Paused","read.pause":"Pause","read.resume":"Resume","read.restart":"Read again","read.stop":"Stop reading","read.title":"Read the last message aloud","command.read":"Read the last message aloud","command.read.description":"Speaks the last reply with a system voice","read.noSession":"Open a conversation to read its last message.","read.empty":"The last message has no text to read.","read.error":"The last message could not be read.","read.noVoices":"The system has no voices installed for reading.","speech.section":"Read aloud","speech.engine":"Voice engine","speech.engine.description":"Edge neural sounds much more natural but needs internet. System uses the installed Windows voices, offline.","speech.engine.edge":"Edge neural (online)","speech.engine.system":"System (offline)","error.edge":"The Edge voice could not be used. Check the connection or switch to the system voice.","speech.edge.error":"Edge voices could not be loaded","speech.voice":"Voice","speech.voice.description":"The voice used for reading.","speech.voice.auto":"Automatic","speech.rate":"Speed","speech.rate.description":"How fast the text is spoken.","speech.rate.slow":"0.8×","speech.rate.normal":"1×","speech.rate.fast":"1.2×","speech.rate.faster":"1.5×","speech.skipCode":"Skip code blocks","speech.skipCode.description":"Read only the prose; code, paths and commands are left out.","speech.preview":"Test voice","speech.preview.description":"Speaks a short sample with the current voice and speed.","speech.preview.text":"Hello, this is a reading test."},es:{"pill.idle":"Dictar","pill.starting":"Iniciando…","pill.listening":"Escuchando…","pill.dictating":"Dictando…","pill.error":"Reintentar dictado","pill.title":"Dictado por voz","pill.winH":"Abrir el dictado de Windows (Win+H)","pill.stop":"Detener dictado","command.toggle":"Alternar dictado por voz","command.toggle.description":"Dicta en el prompt con el motor elegido","command.configure":"Ajustes de dictado por voz","error.title":"Dictado por voz","error.language":"El idioma de reconocimiento {locale} no está instalado. Disponibles: {available}.","error.none":"Windows no tiene ningún reconocedor de voz instalado. Configuración › Hora e idioma › Idioma › Voz.","error.mic":"No hay ningún micrófono disponible para el reconocimiento de voz.","error.system":"System.Speech no está disponible en esta instalación de Windows.","error.exit":"El motor de dictado se detuvo inesperadamente.","error.unknown":"El dictado falló ({code}).","toast.discarded":"Dictado descartado: la sesión activa cambió.","settings.title":"Dictado por voz","settings.description":"Dictado offline en Windows y Linux. El texto se inserta en el prompt; nunca se envía solo.","settings.section":"Dictado","settings.engine":"Motor de dictado","settings.engine.description":"Windows ofrece Win+H y System.Speech; Linux usa whisper.cpp en local.","engine.windows":"Windows Voice Typing (Win+H)","engine.system":"System.Speech (offline)","engine.whisper":"WhisperX local (WSL)","engine.whispercpp":"whisper.cpp (local, nativo)","pill.recording":"Grabando…","pill.transcribing":"Transcribiendo…","pill.whisper":"Grabar y transcribir con WhisperX","error.whisper":"WhisperX no pudo transcribir el audio.","whisper.section":"WhisperX (local)","whisper.python":"Python del entorno","whisper.python.description":"Python del venv que tiene whisperx instalado.","whisper.model":"Modelo","whisper.model.description":"large-v3-turbo es el más rápido con buena precisión en GPU.","whisper.language":"Idioma","whisper.language.description":"Automático detecta el idioma de cada grabación.","whisper.language.auto":"Automático","whisper.test":"Comprobar WhisperX","whisper.test.description":"Arranca el worker e indica si el modelo carga.","whisper.test.run":"Comprobar","whisper.test.ready":"WhisperX listo: {model} ({device})","whisper.test.error":"WhisperX no está listo: {error}","whisper.empty":"WhisperX no devolvió texto.","pill.whispercpp":"Grabar y transcribir con whisper.cpp","error.whispercpp":"whisper.cpp no pudo transcribir el audio.","whispercpp.section":"whisper.cpp (local, nativo)","whispercpp.description":"Funciona en CPU sin WSL ni Python. La primera vez hay que descargar el servidor (~10 MB) y el modelo.","whispercpp.model":"Modelo","whispercpp.model.description":"base-q5_1 es el más ligero (57 MB) y va bien en portátiles modestos. small-q5_1 es más preciso pero consume más CPU y memoria.","whispercpp.language":"Idioma","whispercpp.language.description":"Automático detecta el idioma de cada grabación.","whispercpp.language.auto":"Automático","whispercpp.install":"Descargar motor","whispercpp.install.description":"Descarga el servidor oficial de whisper.cpp y el modelo elegido en ~/.local/share/opencode-voice-dictation. Solo necesita internet una vez.","whispercpp.install.run":"Descargar","whispercpp.install.busy":"Descargando…","whispercpp.install.ok":"whisper.cpp instalado: {model}","whispercpp.install.error":"No se pudo instalar whisper.cpp: {error}","whispercpp.test":"Comprobar whisper.cpp","whispercpp.test.description":"Indica si el servidor, el modelo elegido y ffmpeg están listos.","whispercpp.test.run":"Comprobar","whispercpp.test.ready":"whisper.cpp listo: {model}","whispercpp.test.missing":"whisper.cpp no está instalado. Descarga el motor primero.","whispercpp.test.error":"whisper.cpp no está listo: {error}","whispercpp.empty":"whisper.cpp no devolvió texto.","whispercpp.ffmpeg":"ffmpeg","whispercpp.ffmpeg.description":"Necesario para convertir la grabación WebM a WAV de 16 kHz. El plugin puede instalarlo con tu contraseña del sistema.","whispercpp.ffmpeg.ready":"ffmpeg listo: {version}","whispercpp.ffmpeg.missing":"ffmpeg no está instalado. Sin él no se puede transcribir el audio.","whispercpp.ffmpeg.install":"Instalar ffmpeg","whispercpp.ffmpeg.install.busy":"Instalando…","whispercpp.ffmpeg.install.ok":"ffmpeg instalado correctamente.","whispercpp.ffmpeg.install.error":"No se pudo instalar ffmpeg: {error}","whispercpp.ffmpeg.install.manual":"Ejecútalo manualmente: {command}","error.ffmpeg":"Falta ffmpeg, necesario para convertir el audio. Ve a Ajustes → Dictado por voz → whisper.cpp e instálalo, o ejecuta: sudo apt install -y ffmpeg","whispercpp.linux":"whisper.cpp solo está disponible en Linux. En Windows usa Win+H, System.Speech o WhisperX.","settings.winh":"Probar Win+H","settings.winh.description":"Envía el atajo del dictado de Windows. Si aparece la barra flotante, funciona.","settings.winh.run":"Probar Win+H","settings.winh.sent":"Win+H enviado. Si no apareció la barra, revisa Configuración › Accesibilidad › Dictado por voz.","settings.language":"Idioma","settings.language.description":"Un reconocedor instalado en Windows. Los acentos y la puntuación dependen de él.","settings.confidence":"Confianza mínima","settings.confidence.description":"Los resultados por debajo de este umbral se ignoran para evitar ruido.","confidence.low":"Baja (0,2)","confidence.normal":"Normal (0,4)","confidence.high":"Alta (0,6)","settings.space":"Añadir un espacio antes del texto","settings.space.description":"Separa el dictado de lo que ya hay en el prompt.","settings.focus":"Enfocar el prompt al terminar","settings.focus.description":"Mueve el cursor al prompt para revisar o enviar el texto.","settings.test.section":"Diagnóstico","settings.test":"Probar micrófono","settings.test.description":"Comprueba los reconocedores instalados y el micrófono predeterminado.","settings.test.run":"Ejecutar prueba","settings.test.success":"Reconocimiento de voz listo: {locale}","settings.test.failed":"No está listo: {reason}","common.reason.no-recognizers":"no hay ningún idioma de voz instalado","common.reason.no-microphone":"no hay micrófono disponible","read.idle":"Leer","read.speaking":"Leyendo…","read.paused":"Pausado","read.pause":"Pausar","read.resume":"Reanudar","read.restart":"Leer de nuevo","read.stop":"Detener lectura","read.title":"Leer en voz alta el último mensaje","command.read":"Leer en voz alta el último mensaje","command.read.description":"Reproduce la última respuesta con una voz del sistema","read.noSession":"Abre una conversación para leer su último mensaje.","read.empty":"El último mensaje no tiene texto para leer.","read.error":"No se pudo leer el último mensaje.","read.noVoices":"El sistema no tiene voces instaladas para la lectura.","speech.section":"Lectura en voz alta","speech.engine":"Motor de voz","speech.engine.description":"Edge neuronal suena mucho más natural pero necesita internet. Sistema usa las voces instaladas del equipo, sin conexión.","speech.engine.edge":"Edge neuronal (online)","speech.engine.system":"Sistema (offline)","error.edge":"No se pudo usar la voz de Edge. Revisa la conexión o cambia a la voz del sistema.","speech.edge.error":"No se pudieron cargar las voces de Edge","speech.voice":"Voz","speech.voice.description":"La voz que se usa para leer.","speech.voice.auto":"Automática","speech.rate":"Velocidad","speech.rate.description":"A qué ritmo se lee el texto.","speech.rate.slow":"0,8×","speech.rate.normal":"1×","speech.rate.fast":"1,2×","speech.rate.faster":"1,5×","speech.skipCode":"Ignorar bloques de código","speech.skipCode.description":"Lee solo la prosa; omite código, rutas y comandos.","speech.preview":"Probar voz","speech.preview.description":"Lee una frase corta con la voz y velocidad actuales.","speech.preview.text":"Hola, esta es una prueba de lectura."}}}),le=$.Ipc.define({id:"voice-dictation.dictation",state:p.Schema.Struct({status:p.Schema.Literals(["idle","starting","listening","error"]),error:p.Schema.String,locale:p.Schema.String}),methods:{start:{input:p.Schema.Struct({locale:p.Schema.String,minConfidence:p.Schema.Number})},stop:{},diagnose:{output:p.Schema.Struct({locales:p.Schema.Array(p.Schema.String),microphone:p.Schema.Boolean,selected:p.Schema.String,error:p.Schema.String})}},events:{final:p.Schema.Struct({text:p.Schema.String,confidence:p.Schema.Number}),error:p.Schema.Struct({code:p.Schema.String,detail:p.Schema.String,available:p.Schema.Array(p.Schema.String)})}}),fe=$.Ipc.define({id:"voice-dictation.speech",methods:{speak:{input:p.Schema.Struct({text:p.Schema.String,rate:p.Schema.Number,voice:p.Schema.String})},stop:{},voices:{output:p.Schema.Array(p.Schema.String)}},events:{done:p.Schema.Struct({ok:p.Schema.Boolean})}}),ce=$.Ipc.define({id:"voice-dictation.voice-typing",methods:{toggle:{}}}),ue=String.raw`param(
2
2
  [string]$Culture = "es-ES",
3
3
  [double]$MinConfidence = 0.4,
4
4
  [switch]$Diagnose
@@ -105,9 +105,9 @@ while ($true) {
105
105
  Start-Sleep -Milliseconds 250
106
106
  }
107
107
  }
108
- `,$e="powershell.exe";function me(){const t=G.join(ne.tmpdir(),"opencode-voice-dictation");M.mkdirSync(t,{recursive:!0});const e=re.createHash("sha1").update(pe).digest("hex").slice(0,12),i=G.join(t,`dictation-${e}.ps1`);return M.writeFileSync(i,pe,"utf8"),i}function we(t){return ee.spawn($e,t,{windowsHide:!0,stdio:["ignore","pipe","pipe"]})}function fe(t){const e=t.pid;if(e===void 0){t.kill();return}ee.spawn("taskkill",["/PID",String(e),"/T","/F"],{windowsHide:!0,stdio:"ignore"}).on("error",()=>t.kill())}function Re(t,e){const i=we(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",me(),"-Culture",t.locale,"-MinConfidence",String(t.minConfidence)]);let d=!1,n="",l="";const u=c=>{switch(c.type){case"ready":e.onReady(c.locale??t.locale);break;case"final":c.text&&e.onFinal(c.text,c.confidence??0);break;case"error":e.onError(c.code??"UNKNOWN",c.detail??"",c.available??[]);break}};return i.stdout?.setEncoding("utf8"),i.stdout?.on("data",c=>{l+=c;let g=l.indexOf(`
109
- `);for(;g>=0;){const h=l.slice(0,g).replace(/\r$/,"").trim();if(l=l.slice(g+1),h)try{u(JSON.parse(h))}catch{}g=l.indexOf(`
110
- `)}}),i.stderr?.setEncoding("utf8"),i.stderr?.on("data",c=>{n=(n+c).slice(-2e3)}),i.on("error",c=>{d||e.onError("ENGINE_START_FAILED",c.message,[])}),i.on("close",c=>{d||e.onExit(c,n.trim().slice(-500))}),{stop(){d=!0,fe(i)}}}function Pe(){return new Promise((t,e)=>{const i=we(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",me(),"-Diagnose"]);let d="",n="",l=!1;const u=(g,h)=>{l||(l=!0,clearTimeout(c),g?e(g):t(h))},c=setTimeout(()=>{fe(i),u(new Error("diagnose timed out"))},2e4);i.stdout?.setEncoding("utf8"),i.stdout?.on("data",g=>{d+=g}),i.stderr?.setEncoding("utf8"),i.stderr?.on("data",g=>{n+=g}),i.on("error",g=>u(g)),i.on("close",()=>{const g=d.split(/\r?\n/).map(h=>h.trim()).filter(h=>h.startsWith("{")).at(-1);if(!g){u(new Error(n.trim()||"no output from PowerShell"));return}try{const h=JSON.parse(g);u(void 0,{locales:h.locales??[],microphone:h.microphone===!0,selected:h.selected??"",error:h.error??""})}catch(h){u(h instanceof Error?h:new Error(String(h)))}})})}const de=String.raw`param(
108
+ `,Re="powershell.exe";function we(){const t=Q.join(ae.tmpdir(),"opencode-voice-dictation");K.mkdirSync(t,{recursive:!0});const e=oe.createHash("sha1").update(ue).digest("hex").slice(0,12),i=Q.join(t,`dictation-${e}.ps1`);return K.writeFileSync(i,ue,"utf8"),i}function ve(t){return ie.spawn(Re,t,{windowsHide:!0,stdio:["ignore","pipe","pipe"]})}function ye(t){const e=t.pid;if(e===void 0){t.kill();return}ie.spawn("taskkill",["/PID",String(e),"/T","/F"],{windowsHide:!0,stdio:"ignore"}).on("error",()=>t.kill())}function Pe(t,e){const i=ve(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",we(),"-Culture",t.locale,"-MinConfidence",String(t.minConfidence)]);let c=!1,n="",s="";const u=a=>{switch(a.type){case"ready":e.onReady(a.locale??t.locale);break;case"final":a.text&&e.onFinal(a.text,a.confidence??0);break;case"error":e.onError(a.code??"UNKNOWN",a.detail??"",a.available??[]);break}};return i.stdout?.setEncoding("utf8"),i.stdout?.on("data",a=>{s+=a;let g=s.indexOf(`
109
+ `);for(;g>=0;){const h=s.slice(0,g).replace(/\r$/,"").trim();if(s=s.slice(g+1),h)try{u(JSON.parse(h))}catch{}g=s.indexOf(`
110
+ `)}}),i.stderr?.setEncoding("utf8"),i.stderr?.on("data",a=>{n=(n+a).slice(-2e3)}),i.on("error",a=>{c||e.onError("ENGINE_START_FAILED",a.message,[])}),i.on("close",a=>{c||e.onExit(a,n.trim().slice(-500))}),{stop(){c=!0,ye(i)}}}function Ie(){return new Promise((t,e)=>{const i=ve(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",we(),"-Diagnose"]);let c="",n="",s=!1;const u=(g,h)=>{s||(s=!0,clearTimeout(a),g?e(g):t(h))},a=setTimeout(()=>{ye(i),u(new Error("diagnose timed out"))},2e4);i.stdout?.setEncoding("utf8"),i.stdout?.on("data",g=>{c+=g}),i.stderr?.setEncoding("utf8"),i.stderr?.on("data",g=>{n+=g}),i.on("error",g=>u(g)),i.on("close",()=>{const g=c.split(/\r?\n/).map(h=>h.trim()).filter(h=>h.startsWith("{")).at(-1);if(!g){u(new Error(n.trim()||"no output from PowerShell"));return}try{const h=JSON.parse(g);u(void 0,{locales:h.locales??[],microphone:h.microphone===!0,selected:h.selected??"",error:h.error??""})}catch(h){u(h instanceof Error?h:new Error(String(h)))}})})}const ge=String.raw`param(
111
111
  [string]$TextFile = "",
112
112
  [int]$Rate = 0,
113
113
  [string]$Voice = "",
@@ -160,7 +160,7 @@ try {
160
160
  } catch { }
161
161
 
162
162
  $synth.Dispose()
163
- `,Te="powershell.exe";function ve(){const t=G.join(ne.tmpdir(),"opencode-voice-dictation");return M.mkdirSync(t,{recursive:!0}),t}function ye(){const t=re.createHash("sha1").update(de).digest("hex").slice(0,12),e=G.join(ve(),`speak-${t}.ps1`);return M.writeFileSync(e,de,"utf8"),e}function Ie(t){const e=G.join(ve(),`speech-${re.randomUUID()}.txt`);return M.writeFileSync(e,t,"utf8"),e}function Se(t){return ee.spawn(Te,t,{windowsHide:!0,stdio:["ignore","pipe","pipe"]})}function be(t){const e=t.pid;if(e===void 0){t.kill();return}ee.spawn("taskkill",["/PID",String(e),"/T","/F"],{windowsHide:!0,stdio:"ignore"}).on("error",()=>t.kill())}function Ae(t){return Math.max(-10,Math.min(10,Math.round((t-1)*5)))}function Ne(t,e){const i=Ie(t.text),d=Se(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",ye(),"-TextFile",i,"-Rate",String(Ae(t.rate)),"-Voice",t.voice]);let n=!1;const l=()=>{try{M.rmSync(i,{force:!0})}catch{}};return d.on("error",()=>{l(),n||e(!1)}),d.on("close",u=>{l(),n||e(u===0)}),{stop(){n=!0,be(d),l()}}}function Le(){return new Promise(t=>{const e=Se(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",ye(),"-ListVoices"]);let i="",d=!1;const n=u=>{d||(d=!0,clearTimeout(l),t(u))},l=setTimeout(()=>{be(e),n([])},1e4);e.stdout?.setEncoding("utf8"),e.stdout?.on("data",u=>{i+=u}),e.on("error",()=>n([])),e.on("close",()=>{const u=i.split(/\r?\n/).map(c=>c.trim()).filter(c=>c.length>0);n(u)})})}const ue=String.raw`$ErrorActionPreference = "Stop"
163
+ `,Te="powershell.exe";function Se(){const t=Q.join(ae.tmpdir(),"opencode-voice-dictation");return K.mkdirSync(t,{recursive:!0}),t}function be(){const t=oe.createHash("sha1").update(ge).digest("hex").slice(0,12),e=Q.join(Se(),`speak-${t}.ps1`);return K.writeFileSync(e,ge,"utf8"),e}function Ae(t){const e=Q.join(Se(),`speech-${oe.randomUUID()}.txt`);return K.writeFileSync(e,t,"utf8"),e}function Ce(t){return ie.spawn(Te,t,{windowsHide:!0,stdio:["ignore","pipe","pipe"]})}function ke(t){const e=t.pid;if(e===void 0){t.kill();return}ie.spawn("taskkill",["/PID",String(e),"/T","/F"],{windowsHide:!0,stdio:"ignore"}).on("error",()=>t.kill())}function We(t){return Math.max(-10,Math.min(10,Math.round((t-1)*5)))}function Ne(t,e){const i=Ae(t.text),c=Ce(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",be(),"-TextFile",i,"-Rate",String(We(t.rate)),"-Voice",t.voice]);let n=!1;const s=()=>{try{K.rmSync(i,{force:!0})}catch{}};return c.on("error",()=>{s(),n||e(!1)}),c.on("close",u=>{s(),n||e(u===0)}),{stop(){n=!0,ke(c),s()}}}function Le(){return new Promise(t=>{const e=Ce(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",be(),"-ListVoices"]);let i="",c=!1;const n=u=>{c||(c=!0,clearTimeout(s),t(u))},s=setTimeout(()=>{ke(e),n([])},1e4);e.stdout?.setEncoding("utf8"),e.stdout?.on("data",u=>{i+=u}),e.on("error",()=>n([])),e.on("close",()=>{const u=i.split(/\r?\n/).map(a=>a.trim()).filter(a=>a.length>0);n(u)})})}const he=String.raw`$ErrorActionPreference = "Stop"
164
164
 
165
165
  Add-Type @"
166
166
  using System;
@@ -187,6 +187,6 @@ public static class WinH {
187
187
  "@
188
188
 
189
189
  [WinH]::Send()
190
- `,We="powershell.exe";function _e(){const t=G.join(ne.tmpdir(),"opencode-voice-dictation");M.mkdirSync(t,{recursive:!0});const e=re.createHash("sha1").update(ue).digest("hex").slice(0,12),i=G.join(t,`win-h-${e}.ps1`);return M.writeFileSync(i,ue,"utf8"),i}function Ve(t){return ee.spawn(We,t,{windowsHide:!0,stdio:"ignore"})}function ze(){return new Promise((t,e)=>{const i=Ve(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",_e()]);i.on("error",d=>e(d)),i.on("close",d=>{d===0?t():e(new Error(`Win+H helper exited with code ${d??"unknown"}`))})})}const De=t=>{let e="idle",i="",d="",n,l=!1;const u=()=>{n=void 0,l=!1},c=t.provide(oe,{state:()=>({status:e,error:i,locale:d}),start(y,k){n&&(l=!0,n.stop(),u()),e="starting",i="",d=y.locale,c.changed();const w=k.window;try{n=Re({locale:y.locale,minConfidence:y.minConfidence},{onReady(b){e="listening",d=b,c.changed()},onFinal(b,R){c.emit("final",{text:b,confidence:R},w)},onError(b,R,_){e="error",i=b,c.changed(),c.emit("error",{code:b,detail:R,available:_},w)},onExit(b,R){if(l){u(),e="idle",i="",d="",c.changed();return}u(),e!=="error"&&(e="error",i=b===null?"ENGINE_EXIT":`ENGINE_EXIT_${b}`,c.changed(),c.emit("error",{code:"ENGINE_EXIT",detail:R||(b===null?"":String(b)),available:[]},w))}})}catch(b){throw e="error",i="ENGINE_START_FAILED",c.changed(),b}},stop(){n&&(l=!0,n.stop(),u()),e="idle",i="",d="",c.changed()},diagnose(){return Pe()}});let g;const h=t.provide(he,{speak(y){g?.stop(),g=Ne(y,k=>{g=void 0,h.emit("done",{ok:k})})},stop(){g&&(g.stop(),g=void 0)},voices(){return Le()}});t.provide(se,{toggle(){return ze()}}),t.onCleanup(()=>{n&&(l=!0,n.stop(),u()),g?.stop(),g=void 0})},U={id:"voice-dictation.voice",methods:{edgeVoices:{input:{type:"object",additionalProperties:!1},output:{type:"array",items:{type:"object",properties:{id:{type:"string"},label:{type:"string"},locale:{type:"string"},gender:{type:"string"}},required:["id","label","locale","gender"],additionalProperties:!1}}},edgeSpeak:{input:{type:"object",properties:{text:{type:"string"},voice:{type:"string"},rate:{type:"number"}},required:["text","voice","rate"],additionalProperties:!1},output:{type:"string"}},whisperTranscribe:{input:{type:"object",properties:{audio:{type:"string"},mime:{type:"string"},language:{type:"string"},python:{type:"string"},model:{type:"string"}},required:["audio","mime","language","python","model"],additionalProperties:!1},output:{type:"object",properties:{text:{type:"string"},language:{type:"string"}},required:["text","language"],additionalProperties:!1}},whisperStatus:{input:{type:"object",properties:{python:{type:"string"},model:{type:"string"}},required:["python","model"],additionalProperties:!1},output:{type:"object",properties:{ready:{type:"boolean"},model:{type:"string"},device:{type:"string"},error:{type:"string"}},required:["ready","model","device","error"],additionalProperties:!1}},capabilities:{input:{type:"object",additionalProperties:!1},output:{type:"object",properties:{platform:{type:"string"},engines:{type:"array",items:{type:"string"}}},required:["platform","engines"],additionalProperties:!1}},whisperCppInstall:{input:{type:"object",properties:{model:{type:"string"}},required:["model"],additionalProperties:!1},output:{type:"object",properties:{ok:{type:"boolean"},model:{type:"string"},error:{type:"string"}},required:["ok","model","error"],additionalProperties:!1}},whisperCppStatus:{input:{type:"object",properties:{model:{type:"string"}},required:["model"],additionalProperties:!1},output:{type:"object",properties:{installed:{type:"boolean"},ready:{type:"boolean"},model:{type:"string"},error:{type:"string"}},required:["installed","ready","model","error"],additionalProperties:!1}},whisperCppTranscribe:{input:{type:"object",properties:{audio:{type:"string"},mime:{type:"string"},language:{type:"string"},model:{type:"string"}},required:["audio","mime","language","model"],additionalProperties:!1},output:{type:"object",properties:{text:{type:"string"},language:{type:"string"}},required:["text","language"],additionalProperties:!1}}},events:{}},Oe=250;function Ue(t,e){let i=t.replace(/\r\n/g,`
191
- `);return e?i=i.replace(/```[\s\S]*?```/g," "):i=i.replace(/```[^\n]*\n([\s\S]*?)```/g,"$1"),i=i.replace(/`([^`]+)`/g,"$1"),i=i.replace(/!\[[^\]]*\]\([^)]*\)/g," "),i=i.replace(/\[([^\]]+)\]\([^)]*\)/g,"$1"),i=i.replace(/^\s*#{1,6}\s+/gm,""),i=i.replace(/^\s*>\s?/gm,""),i=i.replace(/^\s*[-*+]\s+/gm,""),i=i.replace(/^\s*\d+\.\s+/gm,""),i=i.replace(/(\*\*|__)(.*?)\1/g,"$2"),i=i.replace(/(\*|_)(.*?)\1/g,"$2"),i=i.replace(/^\s*\|.*\|\s*$/gm," "),i=i.replace(/\n{2,}/g,". "),i=i.replace(/\n/g," "),i=i.replace(/\s+/g," "),i=i.replace(/\s+([.,;:!?])/g,"$1"),i=i.replace(/([.,;:!?]){2,}/g,"$1"),i.trim()}function ge(t,e=Oe){const i=t.trim();if(!i)return[];const d=i.split(new RegExp("(?<=[.!?…])\\s+")),n=[];let l="";const u=c=>{let g="";for(const h of c.split(/\s+/))h&&(g&&g.length+1+h.length>e?(n.push(g),g=h):g=g?`${g} ${h}`:h);return g};for(const c of d){if(c.length>e){l&&(n.push(l),l=""),l=u(c);continue}l&&l.length+1+c.length>e?(n.push(l),l=c):l=l?`${l} ${c}`:c}return l&&n.push(l),n}function Be(t,e){const i=t.stores.speech,d=t.stores.tts.value,[n,l]=S.createSignal(d.engine==="edge"?"edge":"system"),[u,c]=S.createSignal(d.voice),g=r=>{(r.engine==="edge"||r.engine==="system")&&l(r.engine),r.voice!==void 0&&c(r.voice)},[h,y]=S.createSignal(!1),[k,w]=S.createSignal(!1),[b,R]=S.createSignal([]),[_,z]=S.createSignal(""),[I,D]=S.createSignal("none");let T=0,O=[],B="",N,F;const P=new Map,A=()=>typeof globalThis.speechSynthesis<"u"?globalThis.speechSynthesis:void 0,X=()=>{const r=A()?.getVoices()??[];if(r.length===0)return;const o=i.value.voiceURI;if(o){const C=r.find(Y=>Y.voiceURI===o);if(C)return C}const v=t.locale.locale().split("-")[0]?.toLowerCase()??"es";return r.find(C=>C.lang.toLowerCase().startsWith(v))??r[0]},H=async()=>{if(n()==="edge"){try{const o=await t.client.rpc(U).edgeVoices({});o.length>0?(R(o.map(v=>({id:v.id,label:v.label}))),z("")):z(t.t("speech.edge.error"))}catch(o){z(o instanceof Error?o.message:String(o))}D("edge");return}z("");const r=A()?.getVoices()??[];if(r.length>0){R(r.map(o=>({id:o.voiceURI,label:`${o.name} — ${o.lang}`}))),D("web");return}try{const o=await e.voices();if(o.length>0){R(o.map(v=>({id:v,label:v}))),D("sapi");return}}catch{}D("none")},L=()=>{N&&(N.onended=null,N.onerror=null,N.pause(),N=void 0),F&&(URL.revokeObjectURL(F),F=void 0)},s=()=>{T++,O=[],A()?.cancel(),L(),w(!1),y(!1),I()==="sapi"&&e.stop().catch(()=>{})},m=()=>I()==="web"||I()==="edge",f=()=>{if(!(!h()||k()||!m())){if(I()==="edge"){N?.pause(),w(!0);return}A()?.pause(),w(!0)}},$=()=>{if(!(!h()||!k()||!m())){if(I()==="edge"){N?.play().catch(()=>{}),w(!1);return}A()?.resume(),w(!1)}},K=()=>{k()?$():f()},Z=()=>B?j(B):Promise.resolve(!1),q=r=>{const o=u()||"es-ES-XimenaNeural",v=i.value.rate,C=ge(r,4e3),Y=`${o}|${v}|${r}`,Q=P.get(Y)??[];P.clear(),P.set(Y,Q);const V=T;y(!0),w(!1);const x=W=>{if(V===T){if(W>=C.length){w(!1),y(!1);return}(async()=>{let te=Q[W];try{te||(te=await t.client.rpc(U).edgeSpeak({text:C[W]??"",voice:o,rate:v}),Q[W]=te)}catch{V===T&&(w(!1),y(!1),t.toast.show({variant:"error",title:t.t("read.title"),description:t.t("error.edge")}));return}if(V!==T)return;const Ce=Uint8Array.from(atob(te),Ee=>Ee.charCodeAt(0)),ce=URL.createObjectURL(new Blob([Ce],{type:"audio/mpeg"}));L(),F=ce;const ie=new Audio(ce);N=ie,ie.onended=()=>{V===T&&(L(),x(W+1))},ie.onerror=()=>{V===T&&(L(),w(!1),y(!1))},await ie.play().catch(()=>{V===T&&(L(),w(!1),y(!1),t.toast.show({variant:"error",title:t.t("read.title"),description:t.t("error.edge")}))})})()}};return x(0),!0},j=async r=>{B=r;const o=Ue(r,i.value.skipCode);if(!o)return!1;if(s(),await H(),n()==="edge")return q(o);if(I()==="web"){const v=A();if(!v)return!1;const C=X(),Y=i.value.rate,Q=T;O=ge(o),y(!0),w(!1);const V=()=>{if(Q!==T)return;const x=O.shift();if(!x){w(!1),y(!1);return}const W=new SpeechSynthesisUtterance(x);C&&(W.voice=C),C?.lang&&(W.lang=C.lang),W.rate=Y,W.onend=V,W.onerror=V,v.speak(W)};return V(),!0}if(I()==="sapi"){y(!0),w(!1);try{await e.speak({text:o,rate:i.value.rate,voice:i.value.voiceURI})}catch(v){throw y(!1),v}return!0}throw y(!1),new Error(t.t("read.noVoices"))},J=async()=>{const r=t.sessions.current();if(!r)return"";const v=(await r.server.client.message.list({sessionID:r.id,type:"assistant",order:"desc",limit:1})).data[0];return!v||v.type!=="assistant"?"":v.content.filter(C=>C.type==="text").map(C=>C.text).join(`
192
- `)};return e.on("done",()=>{w(!1),y(!1)}),A()?.addEventListener?.("voiceschanged",()=>{H()}),H(),{speaking:h,paused:k,backend:I,engine:n,voice:u,error:_,canPause:m,setConfig:g,voices:b,refreshVoices:H,lastAssistantText:J,speak:j,cancel:s,pause:f,resume:$,togglePause:K,restart:Z,preview:async()=>{const r=await j(t.t("speech.preview.text"));if(r){const o=n()==="edge"?`Edge · ${u()}`:`${I()} · ${i.value.voiceURI||"auto"}`;t.toast.show({variant:"default",title:t.t("read.title"),description:o})}return r}}}const Fe=["es-ES","es-MX","es-AR","es-CO","pt-BR","pt-PT","en-US","en-GB","fr-FR","de-DE","it-IT"];function He(t){const e=E.usePlugin(),i=e.ui,d=e.stores.prefs,n=e.stores.speech,l=e.stores.tts,u=e.stores.engine,c=e.stores.whisper,g=e.stores.whispercpp,h=e.main(oe),y=e.main(se),[k,w]=S.createSignal([]),[b,R]=S.createSignal(!1),[_,z]=S.createSignal(!1),[I,D]=S.createSignal(!1),[T,O]=S.createSignal(!1),[B,N]=S.createSignal("");S.onMount(()=>{t.speaker.setConfig({engine:l.value.engine,voice:l.value.voice});const r=()=>{h.diagnose().then(o=>{o.locales.length>0&&w(o.locales)}).catch(()=>{})};e.client.rpc(U).capabilities({}).then(o=>{const v=o;N(v.platform),!v.engines.includes(u.value.mode)&&v.engines.length>0&&m(v.engines[0]??"windows"),v.platform!=="linux"&&r()}).catch(()=>r()),t.speaker.refreshVoices()});const F=()=>B()==="linux"?[{value:"whispercpp",label:e.t("engine.whispercpp")}]:[{value:"windows",label:e.t("engine.windows")},{value:"system",label:e.t("engine.system")},{value:"whisper",label:e.t("engine.whisper")}],P=()=>{const r=k(),o=d.value.locale;return(r.includes(o)?r:[o,...r]).map(C=>({value:C,label:C}))},A=()=>{const r=t.speaker.voices().map(o=>({value:o.id,label:o.label}));return l.value.engine==="edge"?r:[{value:"",label:e.t("speech.voice.auto")},...r]},X=()=>l.value.engine==="edge"?l.value.voice:n.value.voiceURI,H=r=>{if(l.value.engine==="edge"){l.update(o=>{o.voice=r}),t.speaker.setConfig({voice:r});return}s({voiceURI:r})},L=r=>{d.update(o=>{r.locale!==void 0&&(o.locale=r.locale),r.minConfidence!==void 0&&(o.minConfidence=r.minConfidence),r.trailingSpace!==void 0&&(o.trailingSpace=r.trailingSpace),r.autoFocus!==void 0&&(o.autoFocus=r.autoFocus)})},s=r=>{n.update(o=>{r.voiceURI!==void 0&&(o.voiceURI=r.voiceURI),r.rate!==void 0&&(o.rate=r.rate),r.skipCode!==void 0&&(o.skipCode=r.skipCode)})},m=r=>{u.update(o=>{o.mode=r})},f=r=>{c.update(o=>{r.python!==void 0&&(o.python=r.python),r.model!==void 0&&(o.model=r.model),r.language!==void 0&&(o.language=r.language)})},$=r=>{g.update(o=>{r.model!==void 0&&(o.model=r.model),r.language!==void 0&&(o.language=r.language)})},K=async()=>{D(!0);try{const r=await e.client.rpc(U).whisperCppInstall({model:g.value.model});r.ok?e.toast.show({variant:"success",title:e.t("pill.title"),description:e.t("whispercpp.install.ok",{model:r.model})}):e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.install.error",{error:r.error||"?"}),persistent:!0})}catch(r){e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.install.error",{error:r instanceof Error?r.message:String(r)}),persistent:!0})}finally{D(!1)}},Z=async()=>{O(!0);try{const r=await e.client.rpc(U).whisperCppStatus({model:g.value.model});r.error&&!r.installed?e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.test.error",{error:r.error}),persistent:!0}):r.installed?e.toast.show({variant:"success",title:e.t("pill.title"),description:e.t("whispercpp.test.ready",{model:r.model})}):e.toast.show({variant:"default",title:e.t("pill.title"),description:e.t("whispercpp.test.missing")})}catch(r){e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.test.error",{error:r instanceof Error?r.message:String(r)})})}finally{O(!1)}},q=async()=>{z(!0);try{const r=await e.client.rpc(U).whisperStatus({python:c.value.python,model:c.value.model});r.ready?e.toast.show({variant:"success",title:e.t("pill.title"),description:e.t("whisper.test.ready",{model:r.model,device:r.device})}):e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whisper.test.error",{error:r.error||"?"})})}catch(r){e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whisper.test.error",{error:r instanceof Error?r.message:String(r)})})}finally{z(!1)}},j=r=>{l.update(o=>{o.engine=r}),t.speaker.setConfig({engine:r}),t.speaker.refreshVoices()},J=async()=>{try{await y.toggle(),e.toast.show({variant:"default",title:e.t("settings.winh"),description:e.t("settings.winh.sent")})}catch(r){e.toast.show({variant:"error",title:e.t("pill.title"),description:r instanceof Error?r.message:e.t("error.winh")})}},ae=async()=>{try{await t.speaker.preview()}catch(r){e.toast.show({variant:"error",title:e.t("read.title"),description:r instanceof Error?r.message:e.t("read.error")})}},le=async()=>{R(!0);try{const r=await h.diagnose();if(r.locales.length===0){e.toast.show({variant:"error",title:e.t("error.title"),description:e.t("settings.test.failed",{reason:e.t("common.reason.no-recognizers")}),persistent:!0});return}if(!r.microphone){e.toast.show({variant:"error",title:e.t("error.title"),description:e.t("settings.test.failed",{reason:e.t("common.reason.no-microphone")}),persistent:!0});return}r.locales.length>0&&!k().length&&w(r.locales),e.toast.show({variant:"success",title:e.t("error.title"),description:e.t("settings.test.success",{locale:r.selected||r.locales[0]||d.value.locale})})}catch(r){e.toast.show({variant:"error",title:e.t("error.title"),description:r instanceof Error?r.message:String(r)})}finally{R(!1)}};return a.createComponent(i.SettingsLayout,{get title(){return e.t("settings.title")},get description(){return e.t("settings.description")},get children(){return[a.createComponent(i.SettingsSection,{get title(){return e.t("settings.section")},get children(){return[a.createComponent(i.SettingsRow,{id:"engine",get title(){return e.t("settings.engine")},get description(){return e.t("settings.engine.description")},get children(){return a.createComponent(i.Select,{get label(){return e.t("settings.engine")},get value(){return u.value.mode},get options(){return F()},onChange:r=>m(r)})}}),a.createComponent(S.Show,{get when(){return u.value.mode==="windows"},get children(){return a.createComponent(i.SettingsRow,{id:"winh",get title(){return e.t("settings.winh")},get description(){return e.t("settings.winh.description")},get children(){return a.createComponent(i.Button,{onClick:()=>{J()},get children(){return e.t("settings.winh.run")}})}})}}),a.createComponent(S.Show,{get when(){return u.value.mode==="whisper"},get children(){return[a.createComponent(i.SettingsRow,{id:"whisper-python",get title(){return e.t("whisper.python")},get description(){return e.t("whisper.python.description")},get children(){return a.createComponent(i.TextInput,{get label(){return e.t("whisper.python")},get value(){return c.value.python},onInput:r=>f({python:r})})}}),a.createComponent(i.SettingsRow,{id:"whisper-model",get title(){return e.t("whisper.model")},get description(){return e.t("whisper.model.description")},get children(){return a.createComponent(i.Select,{get label(){return e.t("whisper.model")},get value(){return c.value.model},options:[{value:"large-v3-turbo",label:"large-v3-turbo"},{value:"large-v3",label:"large-v3"},{value:"medium",label:"medium"}],onChange:r=>f({model:r})})}}),a.createComponent(i.SettingsRow,{id:"whisper-language",get title(){return e.t("whisper.language")},get description(){return e.t("whisper.language.description")},get children(){return a.createComponent(i.Select,{get label(){return e.t("whisper.language")},get value(){return c.value.language},get options(){return[{value:"",label:e.t("whisper.language.auto")},{value:"es",label:"Español"},{value:"en",label:"English"},{value:"pt",label:"Português"}]},onChange:r=>f({language:r})})}}),a.createComponent(i.SettingsRow,{id:"whisper-test",get title(){return e.t("whisper.test")},get description(){return e.t("whisper.test.description")},get children(){return a.createComponent(i.Button,{get busy(){return _()},onClick:()=>{q()},get children(){return e.t("whisper.test.run")}})}})]}}),a.createComponent(S.Show,{get when(){return u.value.mode==="whispercpp"},get children(){return[a.createComponent(i.SettingsRow,{id:"whispercpp-model",get title(){return e.t("whispercpp.model")},get description(){return e.t("whispercpp.model.description")},get children(){return a.createComponent(i.Select,{get label(){return e.t("whispercpp.model")},get value(){return g.value.model},options:[{value:"base-q5_1",label:"base-q5_1 · 57 MB"},{value:"small-q5_1",label:"small-q5_1 · 180 MB"}],onChange:r=>$({model:r})})}}),a.createComponent(i.SettingsRow,{id:"whispercpp-language",get title(){return e.t("whispercpp.language")},get description(){return e.t("whispercpp.language.description")},get children(){return a.createComponent(i.Select,{get label(){return e.t("whispercpp.language")},get value(){return g.value.language},get options(){return[{value:"auto",label:e.t("whispercpp.language.auto")},{value:"es",label:"Español"},{value:"en",label:"English"},{value:"pt",label:"Português"}]},onChange:r=>$({language:r})})}}),a.createComponent(i.SettingsRow,{id:"whispercpp-install",get title(){return e.t("whispercpp.install")},get description(){return e.t("whispercpp.install.description")},get children(){return a.createComponent(i.Button,{get busy(){return I()},onClick:()=>{K()},get children(){return e.t("whispercpp.install.run")}})}}),a.createComponent(i.SettingsRow,{id:"whispercpp-test",get title(){return e.t("whispercpp.test")},get description(){return e.t("whispercpp.test.description")},get children(){return a.createComponent(i.Button,{get busy(){return T()},onClick:()=>{Z()},get children(){return e.t("whispercpp.test.run")}})}})]}}),a.createComponent(S.Show,{get when(){return u.value.mode==="system"},get children(){return[a.createComponent(i.SettingsRow,{id:"language",get title(){return e.t("settings.language")},get description(){return e.t("settings.language.description")},get children(){return a.createComponent(i.Select,{get label(){return e.t("settings.language")},get value(){return d.value.locale},get options(){return a.memo(()=>P().length>0)()?P():Fe.map(r=>({value:r,label:r}))},onChange:r=>L({locale:r})})}}),a.createComponent(i.SettingsRow,{id:"confidence",get title(){return e.t("settings.confidence")},get description(){return e.t("settings.confidence.description")},get children(){return a.createComponent(i.Select,{get label(){return e.t("settings.confidence")},get value(){return String(d.value.minConfidence)},get options(){return[{value:"0.2",label:e.t("confidence.low")},{value:"0.4",label:e.t("confidence.normal")},{value:"0.6",label:e.t("confidence.high")}]},onChange:r=>L({minConfidence:Number(r)||.4})})}}),a.createComponent(i.SettingsRow,{id:"space",get title(){return e.t("settings.space")},get description(){return e.t("settings.space.description")},get children(){return a.createComponent(i.Switch,{get label(){return e.t("settings.space")},hideLabel:!0,get checked(){return d.value.trailingSpace},onChange:r=>L({trailingSpace:r})})}}),a.createComponent(i.SettingsRow,{id:"focus",get title(){return e.t("settings.focus")},get description(){return e.t("settings.focus.description")},get children(){return a.createComponent(i.Switch,{get label(){return e.t("settings.focus")},hideLabel:!0,get checked(){return d.value.autoFocus},onChange:r=>L({autoFocus:r})})}})]}})]}}),a.createComponent(i.SettingsSection,{get title(){return e.t("speech.section")},get children(){return[a.createComponent(i.SettingsRow,{id:"tts-engine",get title(){return e.t("speech.engine")},get description(){return e.t("speech.engine.description")},get children(){return a.createComponent(i.Select,{get label(){return e.t("speech.engine")},get value(){return l.value.engine},get options(){return[{value:"edge",label:e.t("speech.engine.edge")},{value:"system",label:e.t("speech.engine.system")}]},onChange:r=>j(r)})}}),a.createComponent(i.SettingsRow,{id:"voice",get title(){return e.t("speech.voice")},get description(){return e.t("speech.voice.description")},get children(){return a.createComponent(i.Select,{get label(){return e.t("speech.voice")},get value(){return X()},get options(){return A()},onChange:r=>H(r)})}}),a.createComponent(S.Show,{get when(){return t.speaker.error()},get children(){return a.createComponent(i.SettingsRow,{id:"tts-error",get title(){return e.t("speech.edge.error")},get description(){return t.speaker.error()}})}}),a.createComponent(i.SettingsRow,{id:"rate",get title(){return e.t("speech.rate")},get description(){return e.t("speech.rate.description")},get children(){return a.createComponent(i.Select,{get label(){return e.t("speech.rate")},get value(){return String(n.value.rate)},get options(){return[{value:"0.8",label:e.t("speech.rate.slow")},{value:"1",label:e.t("speech.rate.normal")},{value:"1.2",label:e.t("speech.rate.fast")},{value:"1.5",label:e.t("speech.rate.faster")}]},onChange:r=>s({rate:Number(r)||1})})}}),a.createComponent(i.SettingsRow,{id:"skip-code",get title(){return e.t("speech.skipCode")},get description(){return e.t("speech.skipCode.description")},get children(){return a.createComponent(i.Switch,{get label(){return e.t("speech.skipCode")},hideLabel:!0,get checked(){return n.value.skipCode},onChange:r=>s({skipCode:r})})}}),a.createComponent(i.SettingsRow,{id:"preview",get title(){return e.t("speech.preview")},get description(){return e.t("speech.preview.description")},get children(){return a.createComponent(i.Button,{onClick:()=>{ae()},get children(){return e.t("read.idle")}})}})]}}),a.createComponent(S.Show,{get when(){return B()!=="linux"},get children(){return a.createComponent(i.SettingsSection,{get title(){return e.t("settings.test.section")},get children(){return a.createComponent(i.SettingsRow,{id:"test",get title(){return e.t("settings.test")},get description(){return e.t("settings.test.description")},get children(){return a.createComponent(i.Button,{get busy(){return b()},onClick:()=>{le()},get children(){return e.t("settings.test.run")}})}})}})}})]}})}async function qe(){const t=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,echoCancellation:!0,noiseSuppression:!0}}),e=MediaRecorder.isTypeSupported("audio/webm;codecs=opus")?"audio/webm;codecs=opus":"audio/webm",i=new MediaRecorder(t,{mimeType:e}),d=[];return i.ondataavailable=n=>{n.data.size>0&&d.push(n.data)},i.start(),{stop:()=>new Promise(n=>{i.onstop=()=>{t.getTracks().forEach(l=>l.stop()),n(new Blob(d,{type:e}))},i.stop()})}}async function je(t){const e=new Uint8Array(await t.arrayBuffer());let i="";const d=32768;for(let n=0;n<e.length;n+=d)i+=String.fromCharCode(...e.subarray(n,n+d));return btoa(i)}function Me(t){const e=E.usePlugin();return a.memo(()=>t.actions().map(i=>a.createComponent(e.ui.Button,{get variant(){return i.id==="stop"?"danger":"neutral"},get icon(){return i.icon},onClick:()=>{i.run(),t.close()},get children(){return i.label}})))}const Ge=t=>{const e=t.main(oe),i=t.main(he),d=t.main(se),n=Be(t,i),l=t.stores.prefs,u=t.stores.engine,c=t.stores.whisper,g=t.stores.whispercpp,[h,y]=S.createSignal(!1),[k,w]=S.createSignal("idle");t.client.rpc(U).capabilities({}).then(s=>{const m=s;!m.engines.includes(u.value.mode)&&m.engines.length>0&&u.update(f=>{f.mode=m.engines[0]??"windows"})}).catch(()=>{});const b=()=>u.value.mode==="whisper"||u.value.mode==="whispercpp"?u.value.mode:void 0;let R,_;const z=(s,m,f)=>{switch(s){case"LANGUAGE_NOT_AVAILABLE":return t.t("error.language",{locale:m||l.value.locale,available:f.length>0?f.join(", "):"—"});case"NO_RECOGNIZERS":return t.t("error.none");case"MICROPHONE_UNAVAILABLE":return t.t("error.mic");case"SYSTEM_SPEECH_UNAVAILABLE":return t.t("error.system");case"ENGINE_EXIT":return t.t("error.exit");default:return t.t("error.unknown",{code:s})}},I=()=>{h()&&(y(!1),d.toggle().catch(()=>{})),n.cancel(),R=t.sessions.current()?.key,e.start({locale:l.value.locale,minConfidence:l.value.minConfidence}).catch(s=>{t.toast.show({variant:"error",title:t.t("error.title"),description:s instanceof Error?s.message:String(s)})})},D=()=>{e.stop().catch(()=>{})},T=()=>{n.cancel(),t.screen.current()?.composer.focus(),d.toggle().then(()=>y(s=>!s)).catch(s=>{y(!1),t.toast.show({variant:"error",title:t.t("pill.title"),description:s instanceof Error?`${t.t("error.winh")} ${s.message}`:t.t("error.winh")})})},O=()=>t.t(b()==="whispercpp"?"error.whispercpp":"error.whisper"),B=async()=>{if(k()==="idle"){n.cancel();try{_=await qe(),w("recording")}catch(s){t.toast.show({variant:"error",title:t.t("pill.title"),description:s instanceof Error?s.message:O()})}}},N=()=>{const s=_;_=void 0,s&&s.stop().catch(()=>{}),w("idle")},F=async()=>{const s=_;if(_=void 0,!s){w("idle");return}w("transcribing");const m=b();try{const f=await s.stop(),$=await je(f),Z=(m==="whispercpp"?await t.client.rpc(U).whisperCppTranscribe({audio:$,mime:f.type,language:g.value.language,model:g.value.model}):await t.client.rpc(U).whisperTranscribe({audio:$,mime:f.type,language:c.value.language,python:c.value.python,model:c.value.model})).text.trim();if(!Z){t.toast.show({variant:"default",title:t.t("pill.title"),description:t.t(m==="whispercpp"?"whispercpp.empty":"whisper.empty")});return}const q=t.screen.current()?.composer;if(q){const j=q.text(),J=j.length>0&&!/\s$/.test(j)?" ":"";q.insert(J+Z),q.focus()}}catch(f){t.toast.show({variant:"error",title:t.t("pill.title"),description:f instanceof Error?f.message:O()})}finally{w("idle")}},P=()=>{if(b()){k()==="recording"?F():k()==="idle"&&B();return}if(u.value.mode==="windows"){T();return}const s=e.state()?.status??"idle";s==="starting"||s==="listening"?D():I()},A=()=>{if(k()==="recording"&&N(),n.speaking()){n.cancel();return}if(!t.sessions.current()){t.toast.show({variant:"error",title:t.t("read.title"),description:t.t("read.noSession")});return}const s=e.state()?.status??"idle";(s==="starting"||s==="listening")&&e.stop().catch(()=>{}),(async()=>{try{const m=await n.lastAssistantText();if(!m.trim()){t.toast.show({variant:"default",title:t.t("read.title"),description:t.t("read.empty")});return}await n.speak(m)||t.toast.show({variant:"default",title:t.t("read.title"),description:t.t("read.empty")})}catch(m){t.toast.show({variant:"error",title:t.t("read.title"),description:m instanceof Error?m.message:t.t("read.error")})}})()},X=()=>{if(!n.speaking())return[{id:"start",label:t.t("read.idle"),icon:"outline-audio",run:A}];const s=n.paused(),m=[{id:"restart",label:t.t("read.restart"),icon:"outline-reset",run:()=>{n.restart()}}];return n.canPause()&&m.push({id:"pause",label:s?t.t("read.resume"):t.t("read.pause"),icon:"outline-audio",run:()=>n.togglePause()}),m.push({id:"stop",label:t.t("read.stop"),icon:"xmark-small",run:()=>n.cancel()}),m};e.on("final",s=>{const m=t.sessions.current()?.key;if(R&&m&&m!==R){t.toast.show({variant:"error",title:t.t("error.title"),description:t.t("toast.discarded")});return}const f=t.screen.current()?.composer;if(!f)return;const $=f.text(),K=l.value.trailingSpace&&$.length>0&&!/\s$/.test($)?" ":"";f.insert(K+s.text),l.value.autoFocus&&f.focus()}),e.on("error",s=>{t.toast.show({variant:"error",title:t.t("error.title"),description:z(s.code,s.detail,s.available),persistent:s.code==="LANGUAGE_NOT_AVAILABLE"||s.code==="NO_RECOGNIZERS",duration:8e3})});const H=()=>{if(t.app.platform!=="desktop")return;if(u.value.mode==="windows")return h()?{label:t.t("pill.dictating"),icon:"outline-wave-audio",tone:"danger",title:t.t("pill.stop"),run:P}:{label:t.t("pill.idle"),icon:"outline-wave-audio",title:t.t("pill.winH"),run:P};if(u.value.mode==="whisper"||u.value.mode==="whispercpp"){const m=t.t(u.value.mode==="whispercpp"?"pill.whispercpp":"pill.whisper");return k()==="recording"?{label:t.t("pill.recording"),icon:"outline-wave-audio",tone:"danger",title:t.t("pill.stop"),run:P}:k()==="transcribing"?{label:t.t("pill.transcribing"),icon:"outline-audio",title:m}:{label:t.t("pill.idle"),icon:"outline-wave-audio",title:m,run:P}}const s=e.state()?.status??"idle";return s==="starting"?{label:t.t("pill.starting"),icon:"outline-wave-audio",title:t.t("pill.title"),run:P}:s==="listening"?{label:t.t("pill.listening"),icon:"outline-wave-audio",tone:"danger",title:t.t("pill.stop"),run:P}:s==="error"?{label:t.t("pill.error"),icon:"outline-wave-audio",tone:"warning",title:t.t("pill.title"),run:P}:{label:t.t("pill.idle"),icon:"outline-wave-audio",title:t.t("pill.title"),run:P}},L=()=>{if(t.app.platform!=="desktop")return;const s=n.speaking(),m=n.paused(),f=s?X().map($=>({id:$.id,label:$.label,icon:$.icon,tone:$.id==="stop"?"danger":void 0,run:$.run})):[];return{label:s?m?t.t("read.paused"):t.t("read.speaking"):t.t("read.idle"),icon:"outline-audio",tone:s?"success":void 0,title:t.t("read.title"),rows:f.length>0?f:void 0,run:s?void 0:A}};t.add(E.ComposerPill,()=>({id:"dictate",show:()=>H()})),t.add(E.ComposerPill,()=>({id:"read",order:1,show:()=>L(),menu:s=>a.createComponent(Me,{actions:X,get close(){return s.close}})})),t.add(E.Command,()=>({id:"toggle",title:t.t("command.toggle"),description:t.t("command.toggle.description"),keywords:"voice dictation microphone speech dictado voz microfono",group:t.t("pill.title"),section:"prompt",bind:"mod+shift+m",slash:{name:"dictado"},editable:!0,enabled:t.app.platform==="desktop",run:P})),t.add(E.Command,()=>({id:"configure",title:t.t("command.configure"),keywords:"voice dictation microphone speech dictado voz microfono",group:t.t("pill.title"),enabled:t.app.platform==="desktop",run:()=>t.layout.settings(t.id)})),t.add(E.Command,()=>({id:"read",title:t.t("command.read"),description:t.t("command.read.description"),keywords:"read aloud speech voice leer voz altavoz lectura",group:t.t("pill.title"),section:"prompt",bind:"mod+shift+l",slash:{name:"leer"},editable:!0,enabled:t.app.platform==="desktop",run:A})),t.add(E.SettingsPage,{id:t.id,icon:"outline-audio",get title(){return t.t("settings.title")},get entries(){return[{id:"engine",title:t.t("settings.engine"),keywords:"engine motor dictation dictado winh voz"},{id:"winh",title:t.t("settings.winh"),keywords:"winh windows voice typing dictado"},{id:"language",title:t.t("settings.language"),keywords:"voice language idioma voz"},{id:"confidence",title:t.t("settings.confidence"),keywords:"voice confidence confianza"},{id:"test",title:t.t("settings.test"),keywords:"voice microphone microphone test microfono"},{id:"voice",title:t.t("speech.voice"),keywords:"read aloud voice voz lectura altavoz"},{id:"tts-engine",title:t.t("speech.engine"),keywords:"voice engine motor edge neural sistema"},{id:"whisper-python",title:t.t("whisper.python"),keywords:"whisper python venv entorno"},{id:"whisper-model",title:t.t("whisper.model"),keywords:"whisper model modelo"},{id:"whisper-language",title:t.t("whisper.language"),keywords:"whisper language idioma"},{id:"whisper-test",title:t.t("whisper.test"),keywords:"whisper check comprobar"},{id:"whispercpp-model",title:t.t("whispercpp.model"),keywords:"whisper.cpp model modelo"},{id:"whispercpp-language",title:t.t("whispercpp.language"),keywords:"whisper.cpp language idioma"},{id:"whispercpp-install",title:t.t("whispercpp.install"),keywords:"whisper.cpp download descargar instalar"},{id:"whispercpp-test",title:t.t("whispercpp.test"),keywords:"whisper.cpp check comprobar"},{id:"rate",title:t.t("speech.rate"),keywords:"read aloud speed velocidad lectura"},{id:"skip-code",title:t.t("speech.skipCode"),keywords:"read aloud code codigo lectura"},{id:"preview",title:t.t("speech.preview"),keywords:"read aloud test voice probar voz"}]},render:()=>a.createComponent(He,{speaker:n})})},Xe=E.Plugin.define({...ke,setup:Ge,main:De});exports.default=Xe;
190
+ `,_e="powershell.exe";function Ve(){const t=Q.join(ae.tmpdir(),"opencode-voice-dictation");K.mkdirSync(t,{recursive:!0});const e=oe.createHash("sha1").update(he).digest("hex").slice(0,12),i=Q.join(t,`win-h-${e}.ps1`);return K.writeFileSync(i,he,"utf8"),i}function ze(t){return ie.spawn(_e,t,{windowsHide:!0,stdio:"ignore"})}function De(){return new Promise((t,e)=>{const i=ze(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",Ve()]);i.on("error",c=>e(c)),i.on("close",c=>{c===0?t():e(new Error(`Win+H helper exited with code ${c??"unknown"}`))})})}const Fe=t=>{let e="idle",i="",c="",n,s=!1;const u=()=>{n=void 0,s=!1},a=t.provide(le,{state:()=>({status:e,error:i,locale:c}),start(S,R){n&&(s=!0,n.stop(),u()),e="starting",i="",c=S.locale,a.changed();const v=R.window;try{n=Pe({locale:S.locale,minConfidence:S.minConfidence},{onReady(C){e="listening",c=C,a.changed()},onFinal(C,I){a.emit("final",{text:C,confidence:I},v)},onError(C,I,z){e="error",i=C,a.changed(),a.emit("error",{code:C,detail:I,available:z},v)},onExit(C,I){if(s){u(),e="idle",i="",c="",a.changed();return}u(),e!=="error"&&(e="error",i=C===null?"ENGINE_EXIT":`ENGINE_EXIT_${C}`,a.changed(),a.emit("error",{code:"ENGINE_EXIT",detail:I||(C===null?"":String(C)),available:[]},v))}})}catch(C){throw e="error",i="ENGINE_START_FAILED",a.changed(),C}},stop(){n&&(s=!0,n.stop(),u()),e="idle",i="",c="",a.changed()},diagnose(){return Ie()}});let g;const h=t.provide(fe,{speak(S){g?.stop(),g=Ne(S,R=>{g=void 0,h.emit("done",{ok:R})})},stop(){g&&(g.stop(),g=void 0)},voices(){return Le()}});t.provide(ce,{toggle(){return De()}}),t.onCleanup(()=>{n&&(s=!0,n.stop(),u()),g?.stop(),g=void 0})},j={id:"voice-dictation.voice",methods:{edgeVoices:{input:{type:"object",additionalProperties:!1},output:{type:"array",items:{type:"object",properties:{id:{type:"string"},label:{type:"string"},locale:{type:"string"},gender:{type:"string"}},required:["id","label","locale","gender"],additionalProperties:!1}}},edgeSpeak:{input:{type:"object",properties:{text:{type:"string"},voice:{type:"string"},rate:{type:"number"}},required:["text","voice","rate"],additionalProperties:!1},output:{type:"string"}},whisperTranscribe:{input:{type:"object",properties:{audio:{type:"string"},mime:{type:"string"},language:{type:"string"},python:{type:"string"},model:{type:"string"}},required:["audio","mime","language","python","model"],additionalProperties:!1},output:{type:"object",properties:{text:{type:"string"},language:{type:"string"}},required:["text","language"],additionalProperties:!1}},whisperStatus:{input:{type:"object",properties:{python:{type:"string"},model:{type:"string"}},required:["python","model"],additionalProperties:!1},output:{type:"object",properties:{ready:{type:"boolean"},model:{type:"string"},device:{type:"string"},error:{type:"string"}},required:["ready","model","device","error"],additionalProperties:!1}},capabilities:{input:{type:"object",additionalProperties:!1},output:{type:"object",properties:{platform:{type:"string"},engines:{type:"array",items:{type:"string"}}},required:["platform","engines"],additionalProperties:!1}},whisperCppInstall:{input:{type:"object",properties:{model:{type:"string"}},required:["model"],additionalProperties:!1},output:{type:"object",properties:{ok:{type:"boolean"},model:{type:"string"},error:{type:"string"}},required:["ok","model","error"],additionalProperties:!1}},whisperCppStatus:{input:{type:"object",properties:{model:{type:"string"}},required:["model"],additionalProperties:!1},output:{type:"object",properties:{installed:{type:"boolean"},ready:{type:"boolean"},model:{type:"string"},error:{type:"string"},ffmpeg:{type:"boolean"},ffmpegVersion:{type:"string"},ffmpegPath:{type:"string"}},required:["installed","ready","model","error","ffmpeg","ffmpegVersion","ffmpegPath"],additionalProperties:!1}},whisperCppInstallFfmpeg:{input:{type:"object",additionalProperties:!1},output:{type:"object",properties:{ok:{type:"boolean"},error:{type:"string"},command:{type:"string"}},required:["ok","error","command"],additionalProperties:!1}},whisperCppTranscribe:{input:{type:"object",properties:{audio:{type:"string"},mime:{type:"string"},language:{type:"string"},model:{type:"string"}},required:["audio","mime","language","model"],additionalProperties:!1},output:{type:"object",properties:{text:{type:"string"},language:{type:"string"}},required:["text","language"],additionalProperties:!1}}},events:{}},Oe=250;function je(t,e){let i=t.replace(/\r\n/g,`
191
+ `);return e?i=i.replace(/```[\s\S]*?```/g," "):i=i.replace(/```[^\n]*\n([\s\S]*?)```/g,"$1"),i=i.replace(/`([^`]+)`/g,"$1"),i=i.replace(/!\[[^\]]*\]\([^)]*\)/g," "),i=i.replace(/\[([^\]]+)\]\([^)]*\)/g,"$1"),i=i.replace(/^\s*#{1,6}\s+/gm,""),i=i.replace(/^\s*>\s?/gm,""),i=i.replace(/^\s*[-*+]\s+/gm,""),i=i.replace(/^\s*\d+\.\s+/gm,""),i=i.replace(/(\*\*|__)(.*?)\1/g,"$2"),i=i.replace(/(\*|_)(.*?)\1/g,"$2"),i=i.replace(/^\s*\|.*\|\s*$/gm," "),i=i.replace(/\n{2,}/g,". "),i=i.replace(/\n/g," "),i=i.replace(/\s+/g," "),i=i.replace(/\s+([.,;:!?])/g,"$1"),i=i.replace(/([.,;:!?]){2,}/g,"$1"),i.trim()}function me(t,e=Oe){const i=t.trim();if(!i)return[];const c=i.split(new RegExp("(?<=[.!?…])\\s+")),n=[];let s="";const u=a=>{let g="";for(const h of a.split(/\s+/))h&&(g&&g.length+1+h.length>e?(n.push(g),g=h):g=g?`${g} ${h}`:h);return g};for(const a of c){if(a.length>e){s&&(n.push(s),s=""),s=u(a);continue}s&&s.length+1+a.length>e?(n.push(s),s=a):s=s?`${s} ${a}`:a}return s&&n.push(s),n}function Be(t,e){const i=t.stores.speech,c=t.stores.tts.value,[n,s]=y.createSignal(c.engine==="edge"?"edge":"system"),[u,a]=y.createSignal(c.voice),g=w=>{(w.engine==="edge"||w.engine==="system")&&s(w.engine),w.voice!==void 0&&a(w.voice)},[h,S]=y.createSignal(!1),[R,v]=y.createSignal(!1),[C,I]=y.createSignal([]),[z,B]=y.createSignal(""),[N,U]=y.createSignal("none");let A=0,q=[],Z="",L,F;const T=new Map,W=()=>typeof globalThis.speechSynthesis<"u"?globalThis.speechSynthesis:void 0,x=()=>{const w=W()?.getVoices()??[];if(w.length===0)return;const f=i.value.voiceURI;if(f){const P=w.find(X=>X.voiceURI===f);if(P)return P}const k=t.locale.locale().split("-")[0]?.toLowerCase()??"es";return w.find(P=>P.lang.toLowerCase().startsWith(k))??w[0]},_=async()=>{if(n()==="edge"){try{const f=await t.client.rpc(j).edgeVoices({});f.length>0?(I(f.map(k=>({id:k.id,label:k.label}))),B("")):B(t.t("speech.edge.error"))}catch(f){B(f instanceof Error?f.message:String(f))}U("edge");return}B("");const w=W()?.getVoices()??[];if(w.length>0){I(w.map(f=>({id:f.voiceURI,label:`${f.name} — ${f.lang}`}))),U("web");return}try{const f=await e.voices();if(f.length>0){I(f.map(k=>({id:k,label:k}))),U("sapi");return}}catch{}U("none")},H=()=>{L&&(L.onended=null,L.onerror=null,L.pause(),L=void 0),F&&(URL.revokeObjectURL(F),F=void 0)},l=()=>{A++,q=[],W()?.cancel(),H(),v(!1),S(!1),N()==="sapi"&&e.stop().catch(()=>{})},m=()=>N()==="web"||N()==="edge",b=()=>{if(!(!h()||R()||!m())){if(N()==="edge"){L?.pause(),v(!0);return}W()?.pause(),v(!0)}},E=()=>{if(!(!h()||!R()||!m())){if(N()==="edge"){L?.play().catch(()=>{}),v(!1);return}W()?.resume(),v(!1)}},ee=()=>{R()?E():b()},M=()=>Z?G(Z):Promise.resolve(!1),O=w=>{const f=u()||"es-ES-XimenaNeural",k=i.value.rate,P=me(w,4e3),X=`${f}|${k}|${w}`,Y=T.get(X)??[];T.clear(),T.set(X,Y);const V=A;S(!0),v(!1);const r=d=>{if(V===A){if(d>=P.length){v(!1),S(!1);return}(async()=>{let D=Y[d];try{D||(D=await t.client.rpc(j).edgeSpeak({text:P[d]??"",voice:f,rate:k}),Y[d]=D)}catch{V===A&&(v(!1),S(!1),t.toast.show({variant:"error",title:t.t("read.title"),description:t.t("error.edge")}));return}if(V!==A)return;const re=Uint8Array.from(atob(D),Ee=>Ee.charCodeAt(0)),de=URL.createObjectURL(new Blob([re],{type:"audio/mpeg"}));H(),F=de;const ne=new Audio(de);L=ne,ne.onended=()=>{V===A&&(H(),r(d+1))},ne.onerror=()=>{V===A&&(H(),v(!1),S(!1))},await ne.play().catch(()=>{V===A&&(H(),v(!1),S(!1),t.toast.show({variant:"error",title:t.t("read.title"),description:t.t("error.edge")}))})})()}};return r(0),!0},G=async w=>{Z=w;const f=je(w,i.value.skipCode);if(!f)return!1;if(l(),await _(),n()==="edge")return O(f);if(N()==="web"){const k=W();if(!k)return!1;const P=x(),X=i.value.rate,Y=A;q=me(f),S(!0),v(!1);const V=()=>{if(Y!==A)return;const r=q.shift();if(!r){v(!1),S(!1);return}const d=new SpeechSynthesisUtterance(r);P&&(d.voice=P),P?.lang&&(d.lang=P.lang),d.rate=X,d.onend=V,d.onerror=V,k.speak(d)};return V(),!0}if(N()==="sapi"){S(!0),v(!1);try{await e.speak({text:f,rate:i.value.rate,voice:i.value.voiceURI})}catch(k){throw S(!1),k}return!0}throw S(!1),new Error(t.t("read.noVoices"))},J=async()=>{const w=t.sessions.current();if(!w)return"";const k=(await w.server.client.message.list({sessionID:w.id,type:"assistant",order:"desc",limit:1})).data[0];return!k||k.type!=="assistant"?"":k.content.filter(P=>P.type==="text").map(P=>P.text).join(`
192
+ `)};return e.on("done",()=>{v(!1),S(!1)}),W()?.addEventListener?.("voiceschanged",()=>{_()}),_(),{speaking:h,paused:R,backend:N,engine:n,voice:u,error:z,canPause:m,setConfig:g,voices:C,refreshVoices:_,lastAssistantText:J,speak:G,cancel:l,pause:b,resume:E,togglePause:ee,restart:M,preview:async()=>{const w=await G(t.t("speech.preview.text"));if(w){const f=n()==="edge"?`Edge · ${u()}`:`${N()} · ${i.value.voiceURI||"auto"}`;t.toast.show({variant:"default",title:t.t("read.title"),description:f})}return w}}}function te(t,e){if(t instanceof Error&&t.message)return t.message;if(typeof t=="string"&&t)return t;if(t&&typeof t=="object"){const i=t;for(const n of["message","error","detail","defect"]){const s=i[n];if(typeof s=="string"&&s)return s}const c=i.data;if(c&&typeof c=="object"){const n=c.message;if(typeof n=="string"&&n)return n}try{const n=JSON.stringify(t);if(n&&n!=="{}")return n}catch{return e}}return e}const Ue=["es-ES","es-MX","es-AR","es-CO","pt-BR","pt-PT","en-US","en-GB","fr-FR","de-DE","it-IT"];function qe(t){const e=$.usePlugin(),i=e.ui,c=e.stores.prefs,n=e.stores.speech,s=e.stores.tts,u=e.stores.engine,a=e.stores.whisper,g=e.stores.whispercpp,h=e.main(le),S=e.main(ce),[R,v]=y.createSignal([]),[C,I]=y.createSignal(!1),[z,B]=y.createSignal(!1),[N,U]=y.createSignal(!1),[A,q]=y.createSignal(!1),[Z,L]=y.createSignal(!1),[F,T]=y.createSignal(void 0),[W,x]=y.createSignal(""),_=async()=>{const r=await e.client.rpc(j).whisperCppStatus({model:g.value.model});return T({available:r.ffmpeg,version:r.ffmpegVersion}),r},H=()=>{const r=F();return r?r.available?e.t("whispercpp.ffmpeg.ready",{version:r.version}):e.t("whispercpp.ffmpeg.missing"):e.t("whispercpp.ffmpeg.description")};y.onMount(()=>{t.speaker.setConfig({engine:s.value.engine,voice:s.value.voice});const r=()=>{h.diagnose().then(d=>{d.locales.length>0&&v(d.locales)}).catch(()=>{})};e.client.rpc(j).capabilities({}).then(d=>{const D=d;x(D.platform),!D.engines.includes(u.value.mode)&&D.engines.length>0&&G(D.engines[0]??"windows"),D.platform!=="linux"?r():_().catch(()=>{})}).catch(()=>r()),t.speaker.refreshVoices()});const l=()=>W()==="linux"?[{value:"whispercpp",label:e.t("engine.whispercpp")}]:[{value:"windows",label:e.t("engine.windows")},{value:"system",label:e.t("engine.system")},{value:"whisper",label:e.t("engine.whisper")}],m=()=>{const r=R(),d=c.value.locale;return(r.includes(d)?r:[d,...r]).map(re=>({value:re,label:re}))},b=()=>{const r=t.speaker.voices().map(d=>({value:d.id,label:d.label}));return s.value.engine==="edge"?r:[{value:"",label:e.t("speech.voice.auto")},...r]},E=()=>s.value.engine==="edge"?s.value.voice:n.value.voiceURI,ee=r=>{if(s.value.engine==="edge"){s.update(d=>{d.voice=r}),t.speaker.setConfig({voice:r});return}O({voiceURI:r})},M=r=>{c.update(d=>{r.locale!==void 0&&(d.locale=r.locale),r.minConfidence!==void 0&&(d.minConfidence=r.minConfidence),r.trailingSpace!==void 0&&(d.trailingSpace=r.trailingSpace),r.autoFocus!==void 0&&(d.autoFocus=r.autoFocus)})},O=r=>{n.update(d=>{r.voiceURI!==void 0&&(d.voiceURI=r.voiceURI),r.rate!==void 0&&(d.rate=r.rate),r.skipCode!==void 0&&(d.skipCode=r.skipCode)})},G=r=>{u.update(d=>{d.mode=r})},J=r=>{a.update(d=>{r.python!==void 0&&(d.python=r.python),r.model!==void 0&&(d.model=r.model),r.language!==void 0&&(d.language=r.language)})},se=r=>{g.update(d=>{r.model!==void 0&&(d.model=r.model),r.language!==void 0&&(d.language=r.language)})},pe=async()=>{U(!0);try{const r=await e.client.rpc(j).whisperCppInstall({model:g.value.model});r.ok?(e.toast.show({variant:"success",title:e.t("pill.title"),description:e.t("whispercpp.install.ok",{model:r.model})}),await _().catch(()=>{})):e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.install.error",{error:r.error||"?"}),persistent:!0})}catch(r){e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.install.error",{error:te(r,"?")}),persistent:!0})}finally{U(!1)}},w=async()=>{q(!0);try{const r=await _();r.error?e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.test.error",{error:r.error}),persistent:!0}):r.installed?r.ffmpeg?e.toast.show({variant:"success",title:e.t("pill.title"),description:e.t("whispercpp.test.ready",{model:r.model})}):e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.ffmpeg.missing"),persistent:!0}):e.toast.show({variant:"default",title:e.t("pill.title"),description:e.t("whispercpp.test.missing")})}catch(r){e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.test.error",{error:te(r,"?")})})}finally{q(!1)}},f=async()=>{L(!0);try{const r=await e.client.rpc(j).whisperCppInstallFfmpeg({});if(r.ok)e.toast.show({variant:"success",title:e.t("pill.title"),description:e.t("whispercpp.ffmpeg.install.ok")}),await _();else{const d=r.command?` ${e.t("whispercpp.ffmpeg.install.manual",{command:r.command})}`:"";e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.ffmpeg.install.error",{error:r.error||"?"})+d,persistent:!0})}}catch(r){e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whispercpp.ffmpeg.install.error",{error:te(r,"?")}),persistent:!0})}finally{L(!1)}},k=async()=>{B(!0);try{const r=await e.client.rpc(j).whisperStatus({python:a.value.python,model:a.value.model});r.ready?e.toast.show({variant:"success",title:e.t("pill.title"),description:e.t("whisper.test.ready",{model:r.model,device:r.device})}):e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whisper.test.error",{error:r.error||"?"})})}catch(r){e.toast.show({variant:"error",title:e.t("pill.title"),description:e.t("whisper.test.error",{error:r instanceof Error?r.message:String(r)})})}finally{B(!1)}},P=r=>{s.update(d=>{d.engine=r}),t.speaker.setConfig({engine:r}),t.speaker.refreshVoices()},X=async()=>{try{await S.toggle(),e.toast.show({variant:"default",title:e.t("settings.winh"),description:e.t("settings.winh.sent")})}catch(r){e.toast.show({variant:"error",title:e.t("pill.title"),description:r instanceof Error?r.message:e.t("error.winh")})}},Y=async()=>{try{await t.speaker.preview()}catch(r){e.toast.show({variant:"error",title:e.t("read.title"),description:r instanceof Error?r.message:e.t("read.error")})}},V=async()=>{I(!0);try{const r=await h.diagnose();if(r.locales.length===0){e.toast.show({variant:"error",title:e.t("error.title"),description:e.t("settings.test.failed",{reason:e.t("common.reason.no-recognizers")}),persistent:!0});return}if(!r.microphone){e.toast.show({variant:"error",title:e.t("error.title"),description:e.t("settings.test.failed",{reason:e.t("common.reason.no-microphone")}),persistent:!0});return}r.locales.length>0&&!R().length&&v(r.locales),e.toast.show({variant:"success",title:e.t("error.title"),description:e.t("settings.test.success",{locale:r.selected||r.locales[0]||c.value.locale})})}catch(r){e.toast.show({variant:"error",title:e.t("error.title"),description:r instanceof Error?r.message:String(r)})}finally{I(!1)}};return o.createComponent(i.SettingsLayout,{get title(){return e.t("settings.title")},get description(){return e.t("settings.description")},get children(){return[o.createComponent(i.SettingsSection,{get title(){return e.t("settings.section")},get children(){return[o.createComponent(i.SettingsRow,{id:"engine",get title(){return e.t("settings.engine")},get description(){return e.t("settings.engine.description")},get children(){return o.createComponent(i.Select,{get label(){return e.t("settings.engine")},get value(){return u.value.mode},get options(){return l()},onChange:r=>G(r)})}}),o.createComponent(y.Show,{get when(){return u.value.mode==="windows"},get children(){return o.createComponent(i.SettingsRow,{id:"winh",get title(){return e.t("settings.winh")},get description(){return e.t("settings.winh.description")},get children(){return o.createComponent(i.Button,{onClick:()=>{X()},get children(){return e.t("settings.winh.run")}})}})}}),o.createComponent(y.Show,{get when(){return u.value.mode==="whisper"},get children(){return[o.createComponent(i.SettingsRow,{id:"whisper-python",get title(){return e.t("whisper.python")},get description(){return e.t("whisper.python.description")},get children(){return o.createComponent(i.TextInput,{get label(){return e.t("whisper.python")},get value(){return a.value.python},onInput:r=>J({python:r})})}}),o.createComponent(i.SettingsRow,{id:"whisper-model",get title(){return e.t("whisper.model")},get description(){return e.t("whisper.model.description")},get children(){return o.createComponent(i.Select,{get label(){return e.t("whisper.model")},get value(){return a.value.model},options:[{value:"large-v3-turbo",label:"large-v3-turbo"},{value:"large-v3",label:"large-v3"},{value:"medium",label:"medium"}],onChange:r=>J({model:r})})}}),o.createComponent(i.SettingsRow,{id:"whisper-language",get title(){return e.t("whisper.language")},get description(){return e.t("whisper.language.description")},get children(){return o.createComponent(i.Select,{get label(){return e.t("whisper.language")},get value(){return a.value.language},get options(){return[{value:"",label:e.t("whisper.language.auto")},{value:"es",label:"Español"},{value:"en",label:"English"},{value:"pt",label:"Português"}]},onChange:r=>J({language:r})})}}),o.createComponent(i.SettingsRow,{id:"whisper-test",get title(){return e.t("whisper.test")},get description(){return e.t("whisper.test.description")},get children(){return o.createComponent(i.Button,{get busy(){return z()},onClick:()=>{k()},get children(){return e.t("whisper.test.run")}})}})]}}),o.createComponent(y.Show,{get when(){return u.value.mode==="whispercpp"},get children(){return[o.createComponent(i.SettingsRow,{id:"whispercpp-model",get title(){return e.t("whispercpp.model")},get description(){return e.t("whispercpp.model.description")},get children(){return o.createComponent(i.Select,{get label(){return e.t("whispercpp.model")},get value(){return g.value.model},options:[{value:"base-q5_1",label:"base-q5_1 · 57 MB"},{value:"small-q5_1",label:"small-q5_1 · 180 MB"}],onChange:r=>se({model:r})})}}),o.createComponent(i.SettingsRow,{id:"whispercpp-language",get title(){return e.t("whispercpp.language")},get description(){return e.t("whispercpp.language.description")},get children(){return o.createComponent(i.Select,{get label(){return e.t("whispercpp.language")},get value(){return g.value.language},get options(){return[{value:"auto",label:e.t("whispercpp.language.auto")},{value:"es",label:"Español"},{value:"en",label:"English"},{value:"pt",label:"Português"}]},onChange:r=>se({language:r})})}}),o.createComponent(i.SettingsRow,{id:"whispercpp-ffmpeg",get title(){return e.t("whispercpp.ffmpeg")},get description(){return H()},get children(){return[o.createComponent(y.Show,{get when(){return F()?.available===!1},get children(){return o.createComponent(i.Button,{get busy(){return Z()},onClick:()=>{f()},get children(){return e.t("whispercpp.ffmpeg.install")}})}}),o.createComponent(y.Show,{get when(){return F()?.available!==!1},get children(){return o.createComponent(i.Button,{onClick:()=>{_().catch(()=>{})},get children(){return e.t("whispercpp.test.run")}})}})]}}),o.createComponent(i.SettingsRow,{id:"whispercpp-install",get title(){return e.t("whispercpp.install")},get description(){return e.t("whispercpp.install.description")},get children(){return o.createComponent(i.Button,{get busy(){return N()},onClick:()=>{pe()},get children(){return e.t("whispercpp.install.run")}})}}),o.createComponent(i.SettingsRow,{id:"whispercpp-test",get title(){return e.t("whispercpp.test")},get description(){return e.t("whispercpp.test.description")},get children(){return o.createComponent(i.Button,{get busy(){return A()},onClick:()=>{w()},get children(){return e.t("whispercpp.test.run")}})}})]}}),o.createComponent(y.Show,{get when(){return u.value.mode==="system"},get children(){return[o.createComponent(i.SettingsRow,{id:"language",get title(){return e.t("settings.language")},get description(){return e.t("settings.language.description")},get children(){return o.createComponent(i.Select,{get label(){return e.t("settings.language")},get value(){return c.value.locale},get options(){return o.memo(()=>m().length>0)()?m():Ue.map(r=>({value:r,label:r}))},onChange:r=>M({locale:r})})}}),o.createComponent(i.SettingsRow,{id:"confidence",get title(){return e.t("settings.confidence")},get description(){return e.t("settings.confidence.description")},get children(){return o.createComponent(i.Select,{get label(){return e.t("settings.confidence")},get value(){return String(c.value.minConfidence)},get options(){return[{value:"0.2",label:e.t("confidence.low")},{value:"0.4",label:e.t("confidence.normal")},{value:"0.6",label:e.t("confidence.high")}]},onChange:r=>M({minConfidence:Number(r)||.4})})}}),o.createComponent(i.SettingsRow,{id:"space",get title(){return e.t("settings.space")},get description(){return e.t("settings.space.description")},get children(){return o.createComponent(i.Switch,{get label(){return e.t("settings.space")},hideLabel:!0,get checked(){return c.value.trailingSpace},onChange:r=>M({trailingSpace:r})})}}),o.createComponent(i.SettingsRow,{id:"focus",get title(){return e.t("settings.focus")},get description(){return e.t("settings.focus.description")},get children(){return o.createComponent(i.Switch,{get label(){return e.t("settings.focus")},hideLabel:!0,get checked(){return c.value.autoFocus},onChange:r=>M({autoFocus:r})})}})]}})]}}),o.createComponent(i.SettingsSection,{get title(){return e.t("speech.section")},get children(){return[o.createComponent(i.SettingsRow,{id:"tts-engine",get title(){return e.t("speech.engine")},get description(){return e.t("speech.engine.description")},get children(){return o.createComponent(i.Select,{get label(){return e.t("speech.engine")},get value(){return s.value.engine},get options(){return[{value:"edge",label:e.t("speech.engine.edge")},{value:"system",label:e.t("speech.engine.system")}]},onChange:r=>P(r)})}}),o.createComponent(i.SettingsRow,{id:"voice",get title(){return e.t("speech.voice")},get description(){return e.t("speech.voice.description")},get children(){return o.createComponent(i.Select,{get label(){return e.t("speech.voice")},get value(){return E()},get options(){return b()},onChange:r=>ee(r)})}}),o.createComponent(y.Show,{get when(){return t.speaker.error()},get children(){return o.createComponent(i.SettingsRow,{id:"tts-error",get title(){return e.t("speech.edge.error")},get description(){return t.speaker.error()}})}}),o.createComponent(i.SettingsRow,{id:"rate",get title(){return e.t("speech.rate")},get description(){return e.t("speech.rate.description")},get children(){return o.createComponent(i.Select,{get label(){return e.t("speech.rate")},get value(){return String(n.value.rate)},get options(){return[{value:"0.8",label:e.t("speech.rate.slow")},{value:"1",label:e.t("speech.rate.normal")},{value:"1.2",label:e.t("speech.rate.fast")},{value:"1.5",label:e.t("speech.rate.faster")}]},onChange:r=>O({rate:Number(r)||1})})}}),o.createComponent(i.SettingsRow,{id:"skip-code",get title(){return e.t("speech.skipCode")},get description(){return e.t("speech.skipCode.description")},get children(){return o.createComponent(i.Switch,{get label(){return e.t("speech.skipCode")},hideLabel:!0,get checked(){return n.value.skipCode},onChange:r=>O({skipCode:r})})}}),o.createComponent(i.SettingsRow,{id:"preview",get title(){return e.t("speech.preview")},get description(){return e.t("speech.preview.description")},get children(){return o.createComponent(i.Button,{onClick:()=>{Y()},get children(){return e.t("read.idle")}})}})]}}),o.createComponent(y.Show,{get when(){return W()!=="linux"},get children(){return o.createComponent(i.SettingsSection,{get title(){return e.t("settings.test.section")},get children(){return o.createComponent(i.SettingsRow,{id:"test",get title(){return e.t("settings.test")},get description(){return e.t("settings.test.description")},get children(){return o.createComponent(i.Button,{get busy(){return C()},onClick:()=>{V()},get children(){return e.t("settings.test.run")}})}})}})}})]}})}async function He(){const t=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,echoCancellation:!0,noiseSuppression:!0}}),e=MediaRecorder.isTypeSupported("audio/webm;codecs=opus")?"audio/webm;codecs=opus":"audio/webm",i=new MediaRecorder(t,{mimeType:e}),c=[];return i.ondataavailable=n=>{n.data.size>0&&c.push(n.data)},i.start(),{stop:()=>new Promise(n=>{i.onstop=()=>{t.getTracks().forEach(s=>s.stop()),n(new Blob(c,{type:e}))},i.stop()})}}async function Me(t){const e=new Uint8Array(await t.arrayBuffer());let i="";const c=32768;for(let n=0;n<e.length;n+=c)i+=String.fromCharCode(...e.subarray(n,n+c));return btoa(i)}function Ge(t){const e=$.usePlugin();return o.memo(()=>t.actions().map(i=>o.createComponent(e.ui.Button,{get variant(){return i.id==="stop"?"danger":"neutral"},get icon(){return i.icon},onClick:()=>{i.run(),t.close()},get children(){return i.label}})))}const Xe=t=>{const e=t.main(le),i=t.main(fe),c=t.main(ce),n=Be(t,i),s=t.stores.prefs,u=t.stores.engine,a=t.stores.whisper,g=t.stores.whispercpp,[h,S]=y.createSignal(!1),[R,v]=y.createSignal("idle");t.client.rpc(j).capabilities({}).then(l=>{const m=l;!m.engines.includes(u.value.mode)&&m.engines.length>0&&u.update(b=>{b.mode=m.engines[0]??"windows"})}).catch(()=>{});const C=()=>u.value.mode==="whisper"||u.value.mode==="whispercpp"?u.value.mode:void 0;let I,z;const B=(l,m,b)=>{switch(l){case"LANGUAGE_NOT_AVAILABLE":return t.t("error.language",{locale:m||s.value.locale,available:b.length>0?b.join(", "):"—"});case"NO_RECOGNIZERS":return t.t("error.none");case"MICROPHONE_UNAVAILABLE":return t.t("error.mic");case"SYSTEM_SPEECH_UNAVAILABLE":return t.t("error.system");case"ENGINE_EXIT":return t.t("error.exit");default:return t.t("error.unknown",{code:l})}},N=()=>{h()&&(S(!1),c.toggle().catch(()=>{})),n.cancel(),I=t.sessions.current()?.key,e.start({locale:s.value.locale,minConfidence:s.value.minConfidence}).catch(l=>{t.toast.show({variant:"error",title:t.t("error.title"),description:l instanceof Error?l.message:String(l)})})},U=()=>{e.stop().catch(()=>{})},A=()=>{n.cancel(),t.screen.current()?.composer.focus(),c.toggle().then(()=>S(l=>!l)).catch(l=>{S(!1),t.toast.show({variant:"error",title:t.t("pill.title"),description:l instanceof Error?`${t.t("error.winh")} ${l.message}`:t.t("error.winh")})})},q=()=>t.t(C()==="whispercpp"?"error.whispercpp":"error.whisper"),Z=async()=>{if(R()==="idle"){n.cancel();try{z=await He(),v("recording")}catch(l){t.toast.show({variant:"error",title:t.t("pill.title"),description:te(l,q())})}}},L=()=>{const l=z;z=void 0,l&&l.stop().catch(()=>{}),v("idle")},F=async()=>{const l=z;if(z=void 0,!l){v("idle");return}v("transcribing");const m=C();try{const b=await l.stop(),E=await Me(b),M=(m==="whispercpp"?await t.client.rpc(j).whisperCppTranscribe({audio:E,mime:b.type,language:g.value.language,model:g.value.model}):await t.client.rpc(j).whisperTranscribe({audio:E,mime:b.type,language:a.value.language,python:a.value.python,model:a.value.model})).text.trim();if(!M){t.toast.show({variant:"default",title:t.t("pill.title"),description:t.t(m==="whispercpp"?"whispercpp.empty":"whisper.empty")});return}const O=t.screen.current()?.composer;if(O){const G=O.text(),J=G.length>0&&!/\s$/.test(G)?" ":"";O.insert(J+M),O.focus()}}catch(b){const E=te(b,q());t.toast.show({variant:"error",title:t.t("pill.title"),description:E.includes("FFMPEG_MISSING")?t.t("error.ffmpeg"):E})}finally{v("idle")}},T=()=>{if(C()){R()==="recording"?F():R()==="idle"&&Z();return}if(u.value.mode==="windows"){A();return}const l=e.state()?.status??"idle";l==="starting"||l==="listening"?U():N()},W=()=>{if(R()==="recording"&&L(),n.speaking()){n.cancel();return}if(!t.sessions.current()){t.toast.show({variant:"error",title:t.t("read.title"),description:t.t("read.noSession")});return}const l=e.state()?.status??"idle";(l==="starting"||l==="listening")&&e.stop().catch(()=>{}),(async()=>{try{const m=await n.lastAssistantText();if(!m.trim()){t.toast.show({variant:"default",title:t.t("read.title"),description:t.t("read.empty")});return}await n.speak(m)||t.toast.show({variant:"default",title:t.t("read.title"),description:t.t("read.empty")})}catch(m){t.toast.show({variant:"error",title:t.t("read.title"),description:m instanceof Error?m.message:t.t("read.error")})}})()},x=()=>{if(!n.speaking())return[{id:"start",label:t.t("read.idle"),icon:"outline-audio",run:W}];const l=n.paused(),m=[{id:"restart",label:t.t("read.restart"),icon:"outline-reset",run:()=>{n.restart()}}];return n.canPause()&&m.push({id:"pause",label:l?t.t("read.resume"):t.t("read.pause"),icon:"outline-audio",run:()=>n.togglePause()}),m.push({id:"stop",label:t.t("read.stop"),icon:"xmark-small",run:()=>n.cancel()}),m};e.on("final",l=>{const m=t.sessions.current()?.key;if(I&&m&&m!==I){t.toast.show({variant:"error",title:t.t("error.title"),description:t.t("toast.discarded")});return}const b=t.screen.current()?.composer;if(!b)return;const E=b.text(),ee=s.value.trailingSpace&&E.length>0&&!/\s$/.test(E)?" ":"";b.insert(ee+l.text),s.value.autoFocus&&b.focus()}),e.on("error",l=>{t.toast.show({variant:"error",title:t.t("error.title"),description:B(l.code,l.detail,l.available),persistent:l.code==="LANGUAGE_NOT_AVAILABLE"||l.code==="NO_RECOGNIZERS",duration:8e3})});const _=()=>{if(t.app.platform!=="desktop")return;if(u.value.mode==="windows")return h()?{label:t.t("pill.dictating"),icon:"outline-wave-audio",tone:"danger",title:t.t("pill.stop"),run:T}:{label:t.t("pill.idle"),icon:"outline-wave-audio",title:t.t("pill.winH"),run:T};if(u.value.mode==="whisper"||u.value.mode==="whispercpp"){const m=t.t(u.value.mode==="whispercpp"?"pill.whispercpp":"pill.whisper");return R()==="recording"?{label:t.t("pill.recording"),icon:"outline-wave-audio",tone:"danger",title:t.t("pill.stop"),run:T}:R()==="transcribing"?{label:t.t("pill.transcribing"),icon:"outline-audio",title:m}:{label:t.t("pill.idle"),icon:"outline-wave-audio",title:m,run:T}}const l=e.state()?.status??"idle";return l==="starting"?{label:t.t("pill.starting"),icon:"outline-wave-audio",title:t.t("pill.title"),run:T}:l==="listening"?{label:t.t("pill.listening"),icon:"outline-wave-audio",tone:"danger",title:t.t("pill.stop"),run:T}:l==="error"?{label:t.t("pill.error"),icon:"outline-wave-audio",tone:"warning",title:t.t("pill.title"),run:T}:{label:t.t("pill.idle"),icon:"outline-wave-audio",title:t.t("pill.title"),run:T}},H=()=>{if(t.app.platform!=="desktop")return;const l=n.speaking(),m=n.paused(),b=l?x().map(E=>({id:E.id,label:E.label,icon:E.icon,tone:E.id==="stop"?"danger":void 0,run:E.run})):[];return{label:l?m?t.t("read.paused"):t.t("read.speaking"):t.t("read.idle"),icon:"outline-audio",tone:l?"success":void 0,title:t.t("read.title"),rows:b.length>0?b:void 0,run:l?void 0:W}};t.add($.ComposerPill,()=>({id:"dictate",show:()=>_()})),t.add($.ComposerPill,()=>({id:"read",order:1,show:()=>H(),menu:l=>o.createComponent(Ge,{actions:x,get close(){return l.close}})})),t.add($.Command,()=>({id:"toggle",title:t.t("command.toggle"),description:t.t("command.toggle.description"),keywords:"voice dictation microphone speech dictado voz microfono",group:t.t("pill.title"),section:"prompt",bind:"mod+shift+m",slash:{name:"dictado"},editable:!0,enabled:t.app.platform==="desktop",run:T})),t.add($.Command,()=>({id:"configure",title:t.t("command.configure"),keywords:"voice dictation microphone speech dictado voz microfono",group:t.t("pill.title"),enabled:t.app.platform==="desktop",run:()=>t.layout.settings(t.id)})),t.add($.Command,()=>({id:"read",title:t.t("command.read"),description:t.t("command.read.description"),keywords:"read aloud speech voice leer voz altavoz lectura",group:t.t("pill.title"),section:"prompt",bind:"mod+shift+l",slash:{name:"leer"},editable:!0,enabled:t.app.platform==="desktop",run:W})),t.add($.SettingsPage,{id:t.id,icon:"outline-audio",get title(){return t.t("settings.title")},get entries(){return[{id:"engine",title:t.t("settings.engine"),keywords:"engine motor dictation dictado winh voz"},{id:"winh",title:t.t("settings.winh"),keywords:"winh windows voice typing dictado"},{id:"language",title:t.t("settings.language"),keywords:"voice language idioma voz"},{id:"confidence",title:t.t("settings.confidence"),keywords:"voice confidence confianza"},{id:"test",title:t.t("settings.test"),keywords:"voice microphone microphone test microfono"},{id:"voice",title:t.t("speech.voice"),keywords:"read aloud voice voz lectura altavoz"},{id:"tts-engine",title:t.t("speech.engine"),keywords:"voice engine motor edge neural sistema"},{id:"whisper-python",title:t.t("whisper.python"),keywords:"whisper python venv entorno"},{id:"whisper-model",title:t.t("whisper.model"),keywords:"whisper model modelo"},{id:"whisper-language",title:t.t("whisper.language"),keywords:"whisper language idioma"},{id:"whisper-test",title:t.t("whisper.test"),keywords:"whisper check comprobar"},{id:"whispercpp-model",title:t.t("whispercpp.model"),keywords:"whisper.cpp model modelo"},{id:"whispercpp-language",title:t.t("whispercpp.language"),keywords:"whisper.cpp language idioma"},{id:"whispercpp-ffmpeg",title:t.t("whispercpp.ffmpeg"),keywords:"whisper.cpp ffmpeg audio convertir"},{id:"whispercpp-install",title:t.t("whispercpp.install"),keywords:"whisper.cpp download descargar instalar"},{id:"whispercpp-test",title:t.t("whispercpp.test"),keywords:"whisper.cpp check comprobar"},{id:"rate",title:t.t("speech.rate"),keywords:"read aloud speed velocidad lectura"},{id:"skip-code",title:t.t("speech.skipCode"),keywords:"read aloud code codigo lectura"},{id:"preview",title:t.t("speech.preview"),keywords:"read aloud test voice probar voz"}]},render:()=>o.createComponent(qe,{speaker:n})})},Ke=$.Plugin.define({...$e,setup:Xe,main:Fe});exports.default=Ke;
@@ -11,7 +11,10 @@ GPU.
11
11
  ## Requisitos
12
12
 
13
13
  - **OpenCode Desktop** en Linux (el plugin GUI no se carga en la consola/TUI).
14
- - **ffmpeg** en el `PATH`:
14
+ - **ffmpeg obligatorio**. Sin ffmpeg no se puede convertir la grabación WebM a
15
+ WAV de 16 kHz y la transcripción falla. El plugin lo detecta y ofrece
16
+ **Instalar ffmpeg** en Ajustes; si no puede, muestra el comando. Instalación
17
+ manual:
15
18
  ```bash
16
19
  sudo apt update && sudo apt install -y ffmpeg
17
20
  ffmpeg -version
@@ -23,18 +26,38 @@ GPU.
23
26
 
24
27
  No hace falta Python, `cmake` ni GPU.
25
28
 
29
+ ## ffmpeg (obligatorio)
30
+
31
+ `whisper-server` solo entiende WAV mono a 16 kHz. La ventana graba WebM/Opus,
32
+ así que el plugin usa `ffmpeg` para convertir el audio antes de transcribir.
33
+
34
+ - **Comprobación**: en Ajustes → Dictado por voz → whisper.cpp verás una fila
35
+ **ffmpeg** con la versión detectada o un aviso de que falta.
36
+ - **Instalación desde el plugin**: pulsa **Instalar ffmpeg**. Se usa `pkexec`
37
+ para mostrar el diálogo de contraseña del sistema (no se guarda). Si `pkexec`
38
+ no está disponible, se intenta `sudo -n`; si tampoco, se muestra el comando
39
+ manual.
40
+ - **Manual**:
41
+ ```bash
42
+ sudo apt update && sudo apt install -y ffmpeg
43
+ ```
44
+ - **Otras distros**: `sudo dnf install -y ffmpeg`, `sudo pacman -S --noconfirm ffmpeg`
45
+ o `sudo zypper install -y ffmpeg`.
46
+
26
47
  ## Instalación desde el plugin
27
48
 
28
49
  1. Instala el plugin (`opencode plugin add opencode-voice-dictation`) y reinicia
29
50
  OpenCode Desktop.
30
51
  2. Abre **Ajustes → Dictado por voz**. En Linux el motor es
31
52
  `whisper.cpp (local, nativo)`.
32
- 3. Elige el modelo:
53
+ 3. Comprueba la fila **ffmpeg**. Si falta, pulsa **Instalar ffmpeg** (o instálalo
54
+ a mano con `sudo apt install -y ffmpeg`).
55
+ 4. Elige el modelo:
33
56
  - `base-q5_1` (57 MB): el más ligero; recomendado para portátiles modestos.
34
57
  - `small-q5_1` (~180 MB): más preciso, consume más CPU y memoria.
35
- 4. Pulsa **Descargar motor**. Se bajan el binario oficial y el modelo.
36
- 5. Pulsa **Comprobar** para verificar que están instalados.
37
- 6. Pulsa la píldora del micrófono, habla y vuelve a pulsarla: el texto se
58
+ 5. Pulsa **Descargar motor**. Se bajan el binario oficial y el modelo.
59
+ 6. Pulsa **Comprobar** para verificar que están instalados.
60
+ 7. Pulsa la píldora del micrófono, habla y vuelve a pulsarla: el texto se
38
61
  inserta en el prompt.
39
62
 
40
63
  La descarga se guarda en:
@@ -99,8 +122,9 @@ whisper-server (proceso persistente)
99
122
  | Motor de dictado | `whisper.cpp` en Linux | Único motor disponible en Linux. |
100
123
  | Modelo | `base-q5_1` | `base-q5_1` (57 MB) o `small-q5_1` (~180 MB). |
101
124
  | Idioma | Automático | Autodetección o `es`, `en`, `pt`. |
125
+ | ffmpeg | — | Comprueba si está instalado y ofrece **Instalar ffmpeg**. Es obligatorio. |
102
126
  | Descargar motor | — | Baja el binario y el modelo seleccionado. |
103
- | Comprobar whisper.cpp | — | Indica si el servidor y el modelo están instalados. |
127
+ | Comprobar whisper.cpp | — | Indica si el servidor, el modelo y ffmpeg están listos. |
104
128
 
105
129
  ## Rendimiento orientativo
106
130
 
@@ -120,7 +144,8 @@ va justo, quédate en `base-q5_1`.
120
144
  |---|---|
121
145
  | «Whisper.cpp is not installed» | Pulsa **Descargar motor** en Ajustes. |
122
146
  | La descarga falla | Revisa internet, proxy o firewall hacia GitHub y Hugging Face. |
123
- | `ffmpeg` no encontrado | `sudo apt install ffmpeg` y comprueba `ffmpeg -version`. |
147
+ | `ffmpeg` no encontrado | Pulsa **Instalar ffmpeg** en Ajustes o ejecuta `sudo apt update && sudo apt install -y ffmpeg`. Comprueba con `ffmpeg -version`. |
148
+ | «Falta ffmpeg» al dictar | Es obligatorio: sin él no se convierte el WebM a WAV. Instálalo desde Ajustes o con `sudo apt install -y ffmpeg`. |
124
149
  | El binario no arranca | `glibc` antigua o arquitectura distinta; compila `whisper.cpp` para tu sistema o usa Ubuntu 22.04+. |
125
150
  | Se queda en «Transcribiendo…» | Mira el toast y el log del servidor de OpenCode; suele ser `ffmpeg` o el binario. |
126
151
  | El RPC devuelve 400 | El plugin server no cargó: Ajustes → Extensiones → Plugins → **Reload**. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-voice-dictation",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Offline voice dictation and read-aloud for OpenCode Desktop on Windows and Linux",
5
5
  "license": "MIT",
6
6
  "author": "Fran Carlos <francarlos803@gmail.com>",
@@ -142,12 +142,22 @@ export const definition = Plugin.define({
142
142
  "whispercpp.install.ok": "whisper.cpp installed: {model}",
143
143
  "whispercpp.install.error": "Could not install whisper.cpp: {error}",
144
144
  "whispercpp.test": "Check whisper.cpp",
145
- "whispercpp.test.description": "Reports whether the server and the selected model are installed.",
145
+ "whispercpp.test.description": "Reports whether the server, the selected model and ffmpeg are ready.",
146
146
  "whispercpp.test.run": "Check",
147
147
  "whispercpp.test.ready": "whisper.cpp ready: {model}",
148
148
  "whispercpp.test.missing": "whisper.cpp is not installed. Download the engine first.",
149
149
  "whispercpp.test.error": "whisper.cpp is not ready: {error}",
150
150
  "whispercpp.empty": "whisper.cpp returned no text.",
151
+ "whispercpp.ffmpeg": "ffmpeg",
152
+ "whispercpp.ffmpeg.description": "Required to convert the WebM recording to 16 kHz WAV. The plugin can install it with your system password.",
153
+ "whispercpp.ffmpeg.ready": "ffmpeg ready: {version}",
154
+ "whispercpp.ffmpeg.missing": "ffmpeg is not installed. Without it the audio cannot be transcribed.",
155
+ "whispercpp.ffmpeg.install": "Install ffmpeg",
156
+ "whispercpp.ffmpeg.install.busy": "Installing…",
157
+ "whispercpp.ffmpeg.install.ok": "ffmpeg installed.",
158
+ "whispercpp.ffmpeg.install.error": "Could not install ffmpeg: {error}",
159
+ "whispercpp.ffmpeg.install.manual": "Run it manually: {command}",
160
+ "error.ffmpeg": "ffmpeg is required to convert the audio but is not installed. Open Settings → Voice dictation → whisper.cpp and install it, or run: sudo apt install -y ffmpeg",
151
161
  "whispercpp.linux": "whisper.cpp is only available on Linux. On Windows use Win+H, System.Speech or WhisperX.",
152
162
  "settings.winh": "Test Win+H",
153
163
  "settings.winh.description": "Sends the Windows voice typing shortcut. If the floating bar appears, it works.",
@@ -272,12 +282,22 @@ export const definition = Plugin.define({
272
282
  "whispercpp.install.ok": "whisper.cpp instalado: {model}",
273
283
  "whispercpp.install.error": "No se pudo instalar whisper.cpp: {error}",
274
284
  "whispercpp.test": "Comprobar whisper.cpp",
275
- "whispercpp.test.description": "Indica si el servidor y el modelo elegido están instalados.",
285
+ "whispercpp.test.description": "Indica si el servidor, el modelo elegido y ffmpeg están listos.",
276
286
  "whispercpp.test.run": "Comprobar",
277
287
  "whispercpp.test.ready": "whisper.cpp listo: {model}",
278
288
  "whispercpp.test.missing": "whisper.cpp no está instalado. Descarga el motor primero.",
279
289
  "whispercpp.test.error": "whisper.cpp no está listo: {error}",
280
290
  "whispercpp.empty": "whisper.cpp no devolvió texto.",
291
+ "whispercpp.ffmpeg": "ffmpeg",
292
+ "whispercpp.ffmpeg.description": "Necesario para convertir la grabación WebM a WAV de 16 kHz. El plugin puede instalarlo con tu contraseña del sistema.",
293
+ "whispercpp.ffmpeg.ready": "ffmpeg listo: {version}",
294
+ "whispercpp.ffmpeg.missing": "ffmpeg no está instalado. Sin él no se puede transcribir el audio.",
295
+ "whispercpp.ffmpeg.install": "Instalar ffmpeg",
296
+ "whispercpp.ffmpeg.install.busy": "Instalando…",
297
+ "whispercpp.ffmpeg.install.ok": "ffmpeg instalado correctamente.",
298
+ "whispercpp.ffmpeg.install.error": "No se pudo instalar ffmpeg: {error}",
299
+ "whispercpp.ffmpeg.install.manual": "Ejecútalo manualmente: {command}",
300
+ "error.ffmpeg": "Falta ffmpeg, necesario para convertir el audio. Ve a Ajustes → Dictado por voz → whisper.cpp e instálalo, o ejecuta: sudo apt install -y ffmpeg",
281
301
  "whispercpp.linux": "whisper.cpp solo está disponible en Linux. En Windows usa Win+H, System.Speech o WhisperX.",
282
302
  "settings.winh": "Probar Win+H",
283
303
  "settings.winh.description": "Envía el atajo del dictado de Windows. Si aparece la barra flotante, funciona.",
@@ -0,0 +1,33 @@
1
+ /**
2
+ * RPC errors do not always arrive as `Error` instances: the bridge may deliver
3
+ * a plain object. Extract the useful text so the UI shows the real cause (for
4
+ * example, a missing ffmpeg) instead of a generic message.
5
+ */
6
+ export function errorMessage(cause: unknown, fallback: string): string {
7
+ if (cause instanceof Error && cause.message) return cause.message
8
+ if (typeof cause === "string" && cause) return cause
9
+
10
+ if (cause && typeof cause === "object") {
11
+ const value = cause as Record<string, unknown>
12
+
13
+ for (const key of ["message", "error", "detail", "defect"]) {
14
+ const found = value[key]
15
+ if (typeof found === "string" && found) return found
16
+ }
17
+
18
+ const data = value.data
19
+ if (data && typeof data === "object") {
20
+ const message = (data as Record<string, unknown>).message
21
+ if (typeof message === "string" && message) return message
22
+ }
23
+
24
+ try {
25
+ const json = JSON.stringify(cause)
26
+ if (json && json !== "{}") return json
27
+ } catch {
28
+ return fallback
29
+ }
30
+ }
31
+
32
+ return fallback
33
+ }
@@ -3,6 +3,7 @@ import { usePlugin } from "@opencode/plugin/gui"
3
3
  import { Dictation, VoiceTyping } from "./contract"
4
4
  import type { definition } from "./definition"
5
5
  import type { Speaker } from "./speak"
6
+ import { errorMessage } from "./errors"
6
7
  import { VoiceRpc } from "../rpc"
7
8
 
8
9
  const FALLBACK_LOCALES = [
@@ -40,8 +41,37 @@ export function VoiceSettings(props: { speaker: Speaker }) {
40
41
  const [whisperBusy, setWhisperBusy] = createSignal(false)
41
42
  const [whisperCppBusy, setWhisperCppBusy] = createSignal(false)
42
43
  const [whisperCppCheckBusy, setWhisperCppCheckBusy] = createSignal(false)
44
+ const [whisperCppFfmpegBusy, setWhisperCppFfmpegBusy] = createSignal(false)
45
+ const [ffmpeg, setFfmpeg] = createSignal<{ available: boolean; version: string } | undefined>(undefined)
43
46
  const [platform, setPlatform] = createSignal("")
44
47
 
48
+ type WhisperCppStatus = {
49
+ installed: boolean
50
+ ready: boolean
51
+ model: string
52
+ error: string
53
+ ffmpeg: boolean
54
+ ffmpegVersion: string
55
+ ffmpegPath: string
56
+ }
57
+
58
+ const readWhisperCppStatus = async (): Promise<WhisperCppStatus> => {
59
+ const status = (await ctx.client.rpc(VoiceRpc).whisperCppStatus({
60
+ model: whisperCppPrefs.value.model,
61
+ })) as WhisperCppStatus
62
+
63
+ setFfmpeg({ available: status.ffmpeg, version: status.ffmpegVersion })
64
+ return status
65
+ }
66
+
67
+ const ffmpegDescription = () => {
68
+ const status = ffmpeg()
69
+ if (!status) return ctx.t("whispercpp.ffmpeg.description")
70
+ return status.available
71
+ ? ctx.t("whispercpp.ffmpeg.ready", { version: status.version })
72
+ : ctx.t("whispercpp.ffmpeg.missing")
73
+ }
74
+
45
75
  onMount(() => {
46
76
  props.speaker.setConfig({ engine: ttsPrefs.value.engine, voice: ttsPrefs.value.voice })
47
77
 
@@ -64,6 +94,7 @@ export function VoiceSettings(props: { speaker: Speaker }) {
64
94
  patchEngine(caps.engines[0] ?? "windows")
65
95
  }
66
96
  if (caps.platform !== "linux") loadLocales()
97
+ else void readWhisperCppStatus().catch(() => {})
67
98
  })
68
99
  .catch(() => loadLocales())
69
100
 
@@ -160,6 +191,7 @@ export function VoiceSettings(props: { speaker: Speaker }) {
160
191
  title: ctx.t("pill.title"),
161
192
  description: ctx.t("whispercpp.install.ok", { model: result.model }),
162
193
  })
194
+ await readWhisperCppStatus().catch(() => {})
163
195
  } else {
164
196
  ctx.toast.show({
165
197
  variant: "error",
@@ -173,7 +205,7 @@ export function VoiceSettings(props: { speaker: Speaker }) {
173
205
  variant: "error",
174
206
  title: ctx.t("pill.title"),
175
207
  description: ctx.t("whispercpp.install.error", {
176
- error: cause instanceof Error ? cause.message : String(cause),
208
+ error: errorMessage(cause, "?"),
177
209
  }),
178
210
  persistent: true,
179
211
  })
@@ -185,11 +217,9 @@ export function VoiceSettings(props: { speaker: Speaker }) {
185
217
  const checkWhisperCpp = async () => {
186
218
  setWhisperCppCheckBusy(true)
187
219
  try {
188
- const status = (await ctx.client.rpc(VoiceRpc).whisperCppStatus({
189
- model: whisperCppPrefs.value.model,
190
- })) as { installed: boolean; ready: boolean; model: string; error: string }
220
+ const status = await readWhisperCppStatus()
191
221
 
192
- if (status.error && !status.installed) {
222
+ if (status.error) {
193
223
  ctx.toast.show({
194
224
  variant: "error",
195
225
  title: ctx.t("pill.title"),
@@ -202,6 +232,13 @@ export function VoiceSettings(props: { speaker: Speaker }) {
202
232
  title: ctx.t("pill.title"),
203
233
  description: ctx.t("whispercpp.test.missing"),
204
234
  })
235
+ } else if (!status.ffmpeg) {
236
+ ctx.toast.show({
237
+ variant: "error",
238
+ title: ctx.t("pill.title"),
239
+ description: ctx.t("whispercpp.ffmpeg.missing"),
240
+ persistent: true,
241
+ })
205
242
  } else {
206
243
  ctx.toast.show({
207
244
  variant: "success",
@@ -214,7 +251,7 @@ export function VoiceSettings(props: { speaker: Speaker }) {
214
251
  variant: "error",
215
252
  title: ctx.t("pill.title"),
216
253
  description: ctx.t("whispercpp.test.error", {
217
- error: cause instanceof Error ? cause.message : String(cause),
254
+ error: errorMessage(cause, "?"),
218
255
  }),
219
256
  })
220
257
  } finally {
@@ -222,6 +259,48 @@ export function VoiceSettings(props: { speaker: Speaker }) {
222
259
  }
223
260
  }
224
261
 
262
+ const installFfmpeg = async () => {
263
+ setWhisperCppFfmpegBusy(true)
264
+ try {
265
+ const result = (await ctx.client.rpc(VoiceRpc).whisperCppInstallFfmpeg({})) as {
266
+ ok: boolean
267
+ error: string
268
+ command: string
269
+ }
270
+
271
+ if (result.ok) {
272
+ ctx.toast.show({
273
+ variant: "success",
274
+ title: ctx.t("pill.title"),
275
+ description: ctx.t("whispercpp.ffmpeg.install.ok"),
276
+ })
277
+ await readWhisperCppStatus()
278
+ } else {
279
+ const manual = result.command
280
+ ? ` ${ctx.t("whispercpp.ffmpeg.install.manual", { command: result.command })}`
281
+ : ""
282
+
283
+ ctx.toast.show({
284
+ variant: "error",
285
+ title: ctx.t("pill.title"),
286
+ description: ctx.t("whispercpp.ffmpeg.install.error", { error: result.error || "?" }) + manual,
287
+ persistent: true,
288
+ })
289
+ }
290
+ } catch (cause) {
291
+ ctx.toast.show({
292
+ variant: "error",
293
+ title: ctx.t("pill.title"),
294
+ description: ctx.t("whispercpp.ffmpeg.install.error", {
295
+ error: errorMessage(cause, "?"),
296
+ }),
297
+ persistent: true,
298
+ })
299
+ } finally {
300
+ setWhisperCppFfmpegBusy(false)
301
+ }
302
+ }
303
+
225
304
  const testWhisper = async () => {
226
305
  setWhisperBusy(true)
227
306
  try {
@@ -456,6 +535,23 @@ export function VoiceSettings(props: { speaker: Speaker }) {
456
535
  />
457
536
  </ui.SettingsRow>
458
537
 
538
+ <ui.SettingsRow
539
+ id="whispercpp-ffmpeg"
540
+ title={ctx.t("whispercpp.ffmpeg")}
541
+ description={ffmpegDescription()}
542
+ >
543
+ <Show when={ffmpeg()?.available === false}>
544
+ <ui.Button busy={whisperCppFfmpegBusy()} onClick={() => void installFfmpeg()}>
545
+ {ctx.t("whispercpp.ffmpeg.install")}
546
+ </ui.Button>
547
+ </Show>
548
+ <Show when={ffmpeg()?.available !== false}>
549
+ <ui.Button onClick={() => void readWhisperCppStatus().catch(() => {})}>
550
+ {ctx.t("whispercpp.test.run")}
551
+ </ui.Button>
552
+ </Show>
553
+ </ui.SettingsRow>
554
+
459
555
  <ui.SettingsRow
460
556
  id="whispercpp-install"
461
557
  title={ctx.t("whispercpp.install")}
@@ -14,6 +14,7 @@ import type { definition } from "./definition"
14
14
  import { createSpeaker, type Speaker } from "./speak"
15
15
  import { VoiceSettings } from "./settings"
16
16
  import { blobToBase64, startRecording, type Recording } from "./recorder"
17
+ import { errorMessage } from "./errors"
17
18
  import { VoiceRpc } from "../rpc"
18
19
 
19
20
  interface ReadAction {
@@ -154,7 +155,7 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
154
155
  ctx.toast.show({
155
156
  variant: "error",
156
157
  title: ctx.t("pill.title"),
157
- description: cause instanceof Error ? cause.message : localError(),
158
+ description: errorMessage(cause, localError()),
158
159
  })
159
160
  }
160
161
  }
@@ -215,10 +216,11 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
215
216
  composer.focus()
216
217
  }
217
218
  } catch (cause) {
219
+ const message = errorMessage(cause, localError())
218
220
  ctx.toast.show({
219
221
  variant: "error",
220
222
  title: ctx.t("pill.title"),
221
- description: cause instanceof Error ? cause.message : localError(),
223
+ description: message.includes("FFMPEG_MISSING") ? ctx.t("error.ffmpeg") : message,
222
224
  })
223
225
  } finally {
224
226
  setWhisperState("idle")
@@ -501,6 +503,7 @@ export const setup: Plugin.Setup<typeof definition> = (ctx) => {
501
503
  { id: "whisper-test", title: ctx.t("whisper.test"), keywords: "whisper check comprobar" },
502
504
  { id: "whispercpp-model", title: ctx.t("whispercpp.model"), keywords: "whisper.cpp model modelo" },
503
505
  { id: "whispercpp-language", title: ctx.t("whispercpp.language"), keywords: "whisper.cpp language idioma" },
506
+ { id: "whispercpp-ffmpeg", title: ctx.t("whispercpp.ffmpeg"), keywords: "whisper.cpp ffmpeg audio convertir" },
504
507
  { id: "whispercpp-install", title: ctx.t("whispercpp.install"), keywords: "whisper.cpp download descargar instalar" },
505
508
  { id: "whispercpp-test", title: ctx.t("whispercpp.test"), keywords: "whisper.cpp check comprobar" },
506
509
  { id: "rate", title: ctx.t("speech.rate"), keywords: "read aloud speed velocidad lectura" },
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import { Plugin } from "@opencode/plugin"
2
2
  import { listEdgeVoices, synthesizeEdge } from "./edge.js"
3
3
  import { VoiceRpc } from "./rpc.js"
4
4
  import { WhisperEngine } from "./whisper/server.js"
5
- import { installWhisperCpp, WhisperCppEngine } from "./whispercpp/server.js"
5
+ import { installWhisperCpp, WhisperCppEngine, installFfmpeg } from "./whispercpp/server.js"
6
6
 
7
7
  /**
8
8
  * Server part of the plugin.
@@ -61,6 +61,7 @@ export default Plugin.define({
61
61
  const { model } = input as { model: string }
62
62
  return whisperCpp.status(model)
63
63
  },
64
+ whisperCppInstallFfmpeg: async () => installFfmpeg(),
64
65
  whisperCppTranscribe: async (input) => {
65
66
  return whisperCpp.transcribe(input as {
66
67
  audio: string
package/src/rpc.ts CHANGED
@@ -138,8 +138,24 @@ export const VoiceRpc = {
138
138
  ready: { type: "boolean" },
139
139
  model: { type: "string" },
140
140
  error: { type: "string" },
141
+ ffmpeg: { type: "boolean" },
142
+ ffmpegVersion: { type: "string" },
143
+ ffmpegPath: { type: "string" },
141
144
  },
142
- required: ["installed", "ready", "model", "error"],
145
+ required: ["installed", "ready", "model", "error", "ffmpeg", "ffmpegVersion", "ffmpegPath"],
146
+ additionalProperties: false,
147
+ },
148
+ },
149
+ whisperCppInstallFfmpeg: {
150
+ input: { type: "object", additionalProperties: false },
151
+ output: {
152
+ type: "object",
153
+ properties: {
154
+ ok: { type: "boolean" },
155
+ error: { type: "string" },
156
+ command: { type: "string" },
157
+ },
158
+ required: ["ok", "error", "command"],
143
159
  additionalProperties: false,
144
160
  },
145
161
  },
@@ -1,6 +1,6 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process"
2
2
  import { randomUUID } from "node:crypto"
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
+ import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
4
4
  import { connect } from "node:net"
5
5
  import { homedir, tmpdir } from "node:os"
6
6
  import { join } from "node:path"
@@ -22,6 +22,9 @@ export interface WhisperCppStatus {
22
22
  ready: boolean
23
23
  model: string
24
24
  error: string
25
+ ffmpeg: boolean
26
+ ffmpegVersion: string
27
+ ffmpegPath: string
25
28
  }
26
29
 
27
30
  export interface WhisperCppResult {
@@ -29,6 +32,25 @@ export interface WhisperCppResult {
29
32
  language: string
30
33
  }
31
34
 
35
+ export interface FfmpegStatus {
36
+ available: boolean
37
+ version: string
38
+ path: string
39
+ error: string
40
+ }
41
+
42
+ export interface InstallFfmpegResult {
43
+ ok: boolean
44
+ error: string
45
+ command: string
46
+ }
47
+
48
+ interface RunResult {
49
+ code: number | null
50
+ stdout: string
51
+ stderr: string
52
+ }
53
+
32
54
  function rootDir(): string {
33
55
  return join(homedir(), ".local", "share", "opencode-voice-dictation", "whisper.cpp")
34
56
  }
@@ -78,6 +100,141 @@ function run(command: string, args: string[]): Promise<string> {
78
100
  })
79
101
  }
80
102
 
103
+ /** Runs a command and returns its output instead of throwing on a non-zero exit. */
104
+ function runCapture(command: string, args: string[]): Promise<RunResult> {
105
+ return new Promise((resolve) => {
106
+ const child = spawn(command, args, { windowsHide: true })
107
+ let stdout = ""
108
+ let stderr = ""
109
+
110
+ child.stdout?.setEncoding("utf8")
111
+ child.stdout?.on("data", (chunk: string) => {
112
+ stdout = (stdout + chunk).slice(-8000)
113
+ })
114
+ child.stderr?.setEncoding("utf8")
115
+ child.stderr?.on("data", (chunk: string) => {
116
+ stderr = (stderr + chunk).slice(-8000)
117
+ })
118
+
119
+ child.on("error", (cause) => {
120
+ resolve({ code: null, stdout, stderr: (stderr + errorText(cause)).slice(-8000) })
121
+ })
122
+ child.on("close", (code) => {
123
+ resolve({ code, stdout, stderr: stderr.slice(-8000) })
124
+ })
125
+ })
126
+ }
127
+
128
+ function errorText(cause: unknown): string {
129
+ return cause instanceof Error ? cause.message : String(cause)
130
+ }
131
+
132
+ function commandExists(command: string): boolean {
133
+ const path = process.env.PATH ?? ""
134
+
135
+ return path.split(":").some((directory) => {
136
+ try {
137
+ accessSync(join(directory || ".", command), constants.X_OK)
138
+ return true
139
+ } catch {
140
+ return false
141
+ }
142
+ })
143
+ }
144
+
145
+ /** Checks that ffmpeg can run. ffmpeg converts the browser WebM to 16 kHz WAV. */
146
+ export async function detectFfmpeg(): Promise<FfmpegStatus> {
147
+ const result = await runCapture("ffmpeg", ["-version"])
148
+
149
+ if (result.code === 0) {
150
+ const version =
151
+ result.stdout
152
+ .split(/\r?\n/)
153
+ .map((line) => line.trim())
154
+ .find((line) => line.startsWith("ffmpeg version")) ?? "ffmpeg"
155
+
156
+ return { available: true, version, path: "ffmpeg", error: "" }
157
+ }
158
+
159
+ return {
160
+ available: false,
161
+ version: "",
162
+ path: "",
163
+ error: result.stderr.trim() || "ffmpeg not found in PATH",
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Installs ffmpeg with the system package manager.
169
+ *
170
+ * Uses `pkexec` (the desktop's authentication dialog) so the user's password is
171
+ * never handled by the plugin. Falls back to passwordless `sudo -n` and, if
172
+ * neither works, returns the command the user can run manually.
173
+ */
174
+ export async function installFfmpeg(): Promise<InstallFfmpegResult> {
175
+ const manual = "sudo apt update && sudo apt install -y ffmpeg"
176
+
177
+ if (process.platform !== "linux") {
178
+ return { ok: false, error: "Automatic ffmpeg installation is only available on Linux", command: manual }
179
+ }
180
+
181
+ const current = await detectFfmpeg()
182
+ if (current.available) {
183
+ return { ok: true, error: "", command: "" }
184
+ }
185
+
186
+ if (!commandExists("apt-get")) {
187
+ const command = commandExists("dnf")
188
+ ? "sudo dnf install -y ffmpeg"
189
+ : commandExists("pacman")
190
+ ? "sudo pacman -S --noconfirm ffmpeg"
191
+ : commandExists("zypper")
192
+ ? "sudo zypper install -y ffmpeg"
193
+ : manual
194
+
195
+ return { ok: false, error: "No supported package manager was found", command }
196
+ }
197
+
198
+ const install = "DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg"
199
+ const updateAndInstall = `DEBIAN_FRONTEND=noninteractive apt-get update && ${install}`
200
+ const attempts: Array<{ command: string; args: string[] }> = []
201
+
202
+ if (commandExists("pkexec")) {
203
+ attempts.push({ command: "pkexec", args: ["sh", "-c", install] })
204
+ attempts.push({ command: "pkexec", args: ["sh", "-c", updateAndInstall] })
205
+ }
206
+
207
+ if (commandExists("sudo")) {
208
+ attempts.push({ command: "sudo", args: ["-n", "sh", "-c", install] })
209
+ attempts.push({ command: "sudo", args: ["-n", "sh", "-c", updateAndInstall] })
210
+ }
211
+
212
+ let lastError = ""
213
+
214
+ for (const attempt of attempts) {
215
+ const result = await runCapture(attempt.command, attempt.args)
216
+
217
+ if (result.code === 0) {
218
+ const after = await detectFfmpeg()
219
+ if (after.available) return { ok: true, error: "", command: "" }
220
+ lastError = after.error || "ffmpeg is still not available"
221
+ continue
222
+ }
223
+
224
+ lastError = result.stderr.trim() || `${attempt.command} exited with code ${result.code ?? "unknown"}`
225
+
226
+ // 126 is "dismissed/not authorized" in pkexec: do not ask for the password
227
+ // again just because the update-and-install fallback exists.
228
+ if (attempt.command === "pkexec" && (result.code === 126 || result.code === 127)) break
229
+ }
230
+
231
+ return {
232
+ ok: false,
233
+ error: lastError || "ffmpeg installation failed",
234
+ command: manual,
235
+ }
236
+ }
237
+
81
238
  export async function installWhisperCpp(model: string): Promise<{ ok: boolean; model: string; error: string }> {
82
239
  if (process.platform !== "linux") {
83
240
  return { ok: false, model: modelFile(model), error: "Whisper.cpp native is only implemented for Linux" }
@@ -216,6 +373,14 @@ export class WhisperCppEngine {
216
373
  language: string
217
374
  model: string
218
375
  }): Promise<WhisperCppResult> {
376
+ const ffmpeg = await detectFfmpeg()
377
+
378
+ if (!ffmpeg.available) {
379
+ throw new Error(
380
+ `FFMPEG_MISSING: ffmpeg is required to convert the audio. Install it with: sudo apt install -y ffmpeg (${ffmpeg.error})`,
381
+ )
382
+ }
383
+
219
384
  const port = await this.ensure(input.model, input.language)
220
385
 
221
386
  const dir = join(tmpdir(), "opencode-voice-dictation")
@@ -228,7 +393,24 @@ export class WhisperCppEngine {
228
393
  writeFileSync(source, Buffer.from(input.audio, "base64"))
229
394
 
230
395
  try {
231
- await run("ffmpeg", ["-y", "-loglevel", "error", "-i", source, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wav])
396
+ try {
397
+ await run("ffmpeg", [
398
+ "-y",
399
+ "-loglevel",
400
+ "error",
401
+ "-i",
402
+ source,
403
+ "-ar",
404
+ "16000",
405
+ "-ac",
406
+ "1",
407
+ "-c:a",
408
+ "pcm_s16le",
409
+ wav,
410
+ ])
411
+ } catch (cause) {
412
+ throw new Error(`ffmpeg failed: ${errorText(cause)}`)
413
+ }
232
414
 
233
415
  const form = new FormData()
234
416
  form.append("file", new Blob([new Uint8Array(readFileSync(wav))], { type: "audio/wav" }), "audio.wav")
@@ -251,8 +433,9 @@ export class WhisperCppEngine {
251
433
  }
252
434
  }
253
435
 
254
- status(model: string): WhisperCppStatus {
436
+ async status(model: string): Promise<WhisperCppStatus> {
255
437
  const file = modelFile(model)
438
+ const ffmpeg = await detectFfmpeg()
256
439
  const installed = existsSync(binaryPath()) && existsSync(modelPath(file))
257
440
 
258
441
  return {
@@ -260,6 +443,9 @@ export class WhisperCppEngine {
260
443
  ready: this.state !== undefined && this.state.model === file,
261
444
  model: file,
262
445
  error: this.error,
446
+ ffmpeg: ffmpeg.available,
447
+ ffmpegVersion: ffmpeg.version,
448
+ ffmpegPath: ffmpeg.path,
263
449
  }
264
450
  }
265
451