Voice Master
Extensión de Chrome que escribe el habla dictada en cualquier campo de texto de cualquier página web, con corrección opcional por IA mediante la propia clave de API del usuario.
- Publicado
- Usuarios
- sobre 2000
- Idiomas de la interfaz
- 17
- Precio
- Gratis
Sobre mí
Voice Master inyecta un panel arrastrable en la página actual, ejecuta la Web Speech API de Chrome e inserta el texto reconocido en el cursor del último campo enfocado: input, textarea, contenteditable o TinyMCE. Encima de eso hay un panel de dictado con su propia área de texto, atajos de teclado, 33 idiomas de dictado y una interfaz en 17 idiomas.
Lo difícil es el propio motor de reconocimiento. webkitSpeechRecognition en modo continuo termina las sesiones por sí mismo tras las pausas y tras unos minutos de trabajo, y lo notifica solo mediante onend. La extensión mantiene viva una sesión de dictado con un watchdog independiente que revive un motor detenido y reinicia uno en marcha con un temporizador, entregando el texto pendiente al campo antes de cada reinicio planificado.
La corrección opcional por IA envía los fragmentos dictados a OpenAI, Anthropic, DeepSeek o Gemini con la propia clave del usuario. Las llamadas salen desde el service worker, los resultados se insertan en el orden en que se habló y una llamada fallida recurre al texto sin procesar. Las claves almacenadas se cifran en reposo y no se envían al JavaScript de la página.
Voice Master · voz a texto
qué se resolvió
Mantener vivo el reconocimiento
en el productoQué aportaEl dictado largo no se detiene por sí solo tras una pausa ni tras unos minutos, y no se pierde nada de lo dicho justo antes de un reinicio.
- Problema
- El reconocimiento de voz continuo de Chrome termina las sesiones por sí mismo tras las pausas y tras unos minutos, sin ninguna señal fiable salvo onend. Un dictado largo se detiene en silencio.
- Solución
Una clase Heartbeat independiente sondea el estado del motor cada 100 ms, revive un motor detenido tras un periodo de gracia y reinicia uno en marcha con un temporizador, y el host entrega el texto pendiente antes de cada reinicio planificado.
- Stack
El motor se detiene por sí solo tras una pausa y el dictado muereEl watchdog actúa cada 100 ms y reinicia el motor tras 1.5 s de inactividad confirmada
Heartbeat.tick() pregunta al host si el reconocimiento está en marcha. Cuando no lo está, registra el momento de la caída y espera un periodo de gracia de 1.5 s antes de llamar a onRevive(), de modo que un ciclo normal de parada/arranque dentro de Chrome no se altera.
Solo hay una revivificación en curso a la vez. Si onstart no llega en 3 s, la revivificación pendiente se descarta y comienza un nuevo periodo de gracia de 1.5 s antes del siguiente intento. El indicador recognizing lo fija solo onstart mediante noteEngineUp(), nunca de forma optimista, de modo que un start() fallido (InvalidStateError) no pueda hacer creer al watchdog que el motor está activo.
Chrome termina una sesión larga por sí mismo, en el momento que eligeRenovar cada 270 s: entregar el texto pendiente, detener el motor y dejar que el watchdog lo reviva
El watchdog guarda la hora de inicio de la subsesión activa actual. Pasados sessionMaxMs (270000 ms, definido en speech.js), llama a refreshSession(), que primero entrega al campo de destino la transcripción final acumulada y la última frase provisional, y luego detiene el motor. end() ve que la parada no fue manual y retorna sin finalizar; los siguientes ticks reviven una sesión nueva.
El intervalo se elige para caer antes del límite de unos cinco minutos que el autor observó en Chrome (indicado en los comentarios del código), de modo que el reinicio ocurre en un momento que controla la extensión en lugar de uno que elige Chrome.
El reinicio automático no debe pelearse con el usuario ni con un micrófono denegadoIndicadores manualStop y fatalError separados condicionan cada revivificación; solo los errores de micrófono y de permisos la detienen definitivamente
onend se dispara en cualquier tipo de parada, así que end() no puede distinguirlas por sí mismo. El host mantiene dos indicadores: manualStop lo fija solo la parada del usuario (botón, atajo de teclado, cierre del panel), y fatalError solo los errores de audio-capture y not-allowed. El watchdog y reviveRecognition() comprueban ambos y permanecen inactivos cuando cualquiera está activo; cualquier otro error, como no-speech, deja la sesión para ser revivida.
Para not-allowed, el manejador compara la hora del error con la hora del clic: un error dentro de los 100 ms significa que el permiso está bloqueado por una política, uno posterior significa que el usuario denegó la solicitud, y el panel muestra un mensaje distinto para cada caso.
Lógica de reinicio enredada con el manejo directo de la Web Speech APIUn envoltorio RecognitionEngine delgado emite callbacks; todo el estado de reinicio permanece en un único host
RecognitionEngine posee solo el objeto webkitSpeechRecognition (continuo, resultados provisionales, 5 alternativas) y analiza los resultados en finalText, interimText y hadFinal. start() deja deliberadamente que InvalidStateError se propague para que quien llama decida si reintentar.
Los indicadores, la acumulación y las decisiones de reinicio residen en speech.js, y la política de tiempos reside en Heartbeat. Las tres partes se comunican mediante pequeñas interfaces de callbacks (isRecognizing, onRevive, onRefresh, onSilence), de modo que el watchdog puede ajustarse sin tocar el código de reconocimiento.
Llevar el texto a cualquier campo
en el productoQué aportaLas palabras dichas aterrizan donde estaba el cursor, en cualquier cuadro de texto o editor de un sitio, y cada frase se escribe solo cuando ha terminado.
- Problema
- El texto reconocido debe aterrizar en el cursor del campo en el que el usuario estaba escribiendo, en páginas que la extensión no controla, mientras el usuario hace clic en el propio panel de la extensión.
- Solución
Un TextInserter sigue el último elemento enfocado elegible y usa el método de inserción que acepta cada tipo de editor; el panel evita robar el foco.
- Stack
Los campos difieren: input, textarea, contenteditable, TinyMCEUn insert() elige splice, execCommand('insertText') o mceInsertContent según el tipo de destino
Para input y textarea, el valor se empalma entre selectionStart y selectionEnd y el cursor se mueve más allá del texto nuevo, de modo que un fragmento seleccionado es sustituido por el dictado. Para contenteditable usa execCommand('insertText'), que también reemplaza la selección actual y permite que el editor vea una edición normal. Para TinyMCE llama al propio comando mceInsertContent del editor.
TinyMCE se inicializa tarde, así que el hook de foco para él se engancha tras un retraso y, si todavía no había nada más enfocado, se enfoca y se sigue el editor activo.
Hacer clic en el panel aparta el foco del campo de destinoSeguir el foco en la fase de captura, conservar el último campo elegible; bloquear el robo de foco en los controles
Un listener de foco en fase de captura sobre document registra cada elemento elegible en una lista ordenada con el más reciente al final, de modo que cuando el usuario hace clic en el panel el destino de inserción sigue siendo el último campo en el que estuvo.
El botón de inicio cancela mousedown y los selectores de idioma cancelan mouseup, de modo que pulsarlos no desenfoca el campo. El panel arrastrable omite el manejo de arrastre en inputs, botones y enlaces para que conserven el comportamiento normal de foco y clic.
Los resultados provisionales cambian constantemente y no deben escribirse antes de tiempoMostrar el texto provisional en una vista previa; entregar la frase solo cuando el flujo provisional se vacía
Los resultados finales se acumulan en finalTranscript. El texto provisional va a un panel de vista previa arrastrable independiente y se recuerda como prev_text; cuando llega una actualización con la parte provisional vacía, buffer() entrega la frase recordada al campo y limpia la vista previa.
El span final se vuelve a renderizar solo cuando llegó un nuevo resultado final, lo que reduce el trabajo sobre el DOM durante un dictado largo.
Canalización de corrección por IA
en el productoQué aportaUna IA elegida por el usuario puede corregir por contexto las palabras mal oídas. El texto conserva el orden en que se habló y, si la IA falla, el texto sin procesar llega igualmente.
- Problema
- Cuatro proveedores de IA con APIs distintas, llamados desde dentro de páginas web arbitrarias con su propio CSP, mientras el dictado sigue produciendo nuevos fragmentos que deben mantenerse en orden.
- Solución
Los adaptadores de proveedores se ejecutan en el service worker; la página habla con ellos mediante un puente de eventos correlacionados, y una cola FIFO con tiempo límite y respaldo en texto sin procesar alimenta el campo.
- Stack
El CSP y el CORS de la página bloquean las llamadas a las APIs de IA desde la páginaTodas las llamadas a APIs se ejecutan en el service worker MV3 con host_permissions para los cuatro hosts
El corrector del lado de la página solo construye el prompt. La petición viaja a través del content script hasta el service worker, que posee el fetch. host_permissions en manifest.json cubre api.openai.com, api.anthropic.com, api.deepseek.com y generativelanguage.googleapis.com.
El listener onMessage del worker devuelve true para mantener abierto el canal de la respuesta asíncrona.
Los cuatro proveedores difieren en autenticación y en la forma de las peticiones y respuestasTabla de adaptadores: por proveedor una URL, un constructor de cabeceras, un constructor de cuerpo, un analizador y un lector de errores
AI_PROVIDERS describe cada API de forma declarativa: cabecera Bearer para OpenAI y DeepSeek, x-api-key más cabeceras de versión para Anthropic, x-goog-api-key para Gemini, y la ruta al texto en cada respuesta. aiCorrect() es igual para todos ellos.
Una clave vacía devuelve el código 'no_key' en lugar de un error, lo que permite a quien llama recurrir en silencio al texto sin procesar. Gemini se añadió como cuarto proveedor con una entrada de tabla más un permiso de host en el manifiesto.
Las respuestas asíncronas de la IA podrían insertar fragmentos fuera del orden en que se hablóCola FIFO de cadena de promesas, tiempo límite de 20 s por fragmento, texto sin procesar insertado ante cualquier fallo
LiveImprover.push() añade cada fragmento a una única cadena de promesas, de modo que el fragmento N+1 se envía solo después de que el fragmento N se haya insertado. Cada llamada compite con un tiempo límite de 20 s. Ante un fallo o un tiempo agotado se inserta el texto original, de modo que el habla dictada nunca se pierde.
Los modelos devuelven texto recortado, así que keepSpacing() restaura los espacios iniciales y finales originales del fragmento para evitar que fragmentos consecutivos se peguen. Sin clave, la cola informa del código 'no_key' y la interfaz permanece en silencio, como si el modo en vivo estuviera desactivado.
El mundo de la página y el mundo de la extensión solo pueden intercambiar eventosID de petición y un mapa de promesas pendientes convierten los eventos de window en llamadas asíncronas
El código del panel se ejecuta como un script de módulo en la página y no tiene APIs chrome.*. GptCorrector.improve() asigna un id de secuencia, guarda el resolver en un Map y despacha stt_ai_correct_request. El content script lo reenvía al worker y responde con stt_ai_correct_response con el mismo id, que resuelve exactamente una promesa pendiente.
El motor de voz oye mal las palabras; un corrector ortográfico simple las dejaUn prompt localizado indica al modelo que el texto es STT sin procesar y que corrija las palabras por contexto
El prompt corr_and_return indica que el texto procede de un reconocedor de voz imperfecto, pide al modelo que sustituya las palabras mal reconocidas por lo que el hablante probablemente quiso decir, que conserve el idioma y el significado, que no añada nada y que devuelva solo el texto. Está traducido a todas las configuraciones regionales de la interfaz.
En el panel de dictado, el texto mejorado reemplaza el contenido del textarea mediante execCommand('insertText'), de modo que Ctrl+Z restaura la versión original.
Gestión de claves de API
shipped · 2026Qué aportaLas claves de IA propias del usuario se almacenan cifradas, y los sitios web en los que aparece el panel no pueden leerlas. Los ajustes permanecen iguales en todas las pestañas abiertas.
- Problema
- Las claves de API del usuario deben ser almacenadas por una extensión cuya interfaz se ejecuta dentro de páginas no fiables, de modo que cualquier script de la página podría leer lo que puede leer el panel.
- Solución
Las claves se cifran y descifran en el service worker, se mantienen en texto plano solo en la memoria del content script, y la página recibe únicamente indicadores y pistas enmascaradas.
- Stack
Las claves de API no deben estar en texto plano en el almacenamiento de la extensiónAES-GCM con un secreto aleatorio de 256 bits por instalación y un IV nuevo de 12 bytes por valor
sttCryptoKey() genera el secreto una vez, lo almacena y lo importa como una CryptoKey no extraíble. Los valores cifrados llevan un prefijo de versión (stt1:) seguido del IV y el texto cifrado en base64.
El cifrado se ejecuta en el service worker porque este siempre es un contexto seguro con crypto.subtle, mientras que un content script en una página http simple no lo es. El comentario del código es explícito en que se trata de una protección en reposo contra la lectura casual, no contra alguien que tenga todo el perfil del navegador.
Los scripts de la página no deben poder leer las claves almacenadasLos eventos de la página llevan solo indicadores hasKeys y pistas enmascaradas; el texto plano permanece en el content script
sttPublicCfg() es la única forma que cruza hacia la página: proveedor, indicador de modo en vivo, un booleano por proveedor y una pista como los 3 primeros y los 4 últimos caracteres. El formulario de ajustes nunca recibe la clave almacenada; un campo sin tocar al guardar significa conservar la clave almacenada (indicador keyTouched).
Los usuarios existentes tenían claves en texto plano en un formato de almacenamiento antiguoEn la primera lectura, las claves heredadas y en texto plano se vuelven a guardar cifradas sin intervención del usuario
sttGetAi() mueve la antigua gptApiKey única a la estructura por proveedor, cifra cualquier valor sin el prefijo stt1:, escribe el resultado y elimina la celda heredada. Si el worker no consigue cifrar, se conserva el texto plano para que el usuario no quede bloqueado, y la migración se reintenta en la siguiente lectura.
sttDecrypt() devuelve una cadena vacía ante un valor corrupto o ajeno, de modo que un registro roto se comporta como 'sin clave' en lugar de lanzar una excepción.
Los ajustes deben mantenerse actualizados en todas las pestañas abiertasstorage.onChanged retransmite la configuración saneada al espejo de página de cada pestaña
El panel de cada pestaña mantiene un espejo local del proveedor, el indicador de modo en vivo y hasKeys. content.js escucha chrome.storage.onChanged para el registro sttAi y empuja a la página una configuración saneada nueva, de modo que liveEnabled() nunca actúa con una clave que se eliminó en otro lugar.
Eliminar una clave en el popover es local hasta Save; cerrar sin guardar conserva la clave almacenada.
Funcionar dentro de cualquier página
en el productoQué aportaEl atajo de teclado funciona incluso en pestañas que estaban abiertas antes de cargar la extensión, y el panel habla el idioma del usuario en cualquier sitio.
- Problema
- El panel vive dentro de páginas de terceros: puede que falten los content scripts y el código del mundo de la página no tiene APIs chrome.*.
- Solución
Inyección bajo demanda con una ruta de respaldo y un puente de localización hacia el almacenamiento de la página.
- Stack
Atajo de teclado pulsado en una pestaña donde el content script no está cargadoProbar sendMessage; ante lastError inyectar content.js con chrome.scripting y reenviar
start() en el service worker envía la conmutación a la pestaña. Si chrome.runtime.lastError indica que nadie escucha, inyecta content.js y repite el mensaje. Después se inyectan bajo demanda el módulo del panel y la hoja de estilos, omitiendo las páginas chrome://.
El popup comprueba lo mismo con un ping isLoaded antes de decidir si enviar el mensaje a la pestaña o pasar por el worker.
El código del panel en el mundo de la página no puede llamar a chrome.i18nEl content script copia cada mensaje al localStorage de la página; en aporta la lista de claves
getLocalizationKeys() lee la lista de claves del messages.json de la configuración regional por defecto, que tiene el conjunto completo, y luego guarda chrome.i18n.getMessage(key) para cada una bajo un prefijo stt_. Helper.getI18nMessage() en la página lee desde ahí.
Como la lista de claves procede de en y los valores de chrome.i18n, una configuración regional con claves ausentes recurre al inglés clave por clave. La configuración regional hi carece actualmente de 7 claves y queda cubierta de este modo.
Cifras
del producto- Usuarios unos 2,000 Chrome Web Store.
- Idiomas de la interfaz 17
- Idiomas de dictado 33 63 variantes regionales.