Voice Master
Extension Chrome qui saisit la parole dictée dans n'importe quel champ de texte de n'importe quelle page web, avec une correction IA facultative via la propre clé d'API de l'utilisateur.
- Sortie
- Utilisateurs
- à propos 2 000
- Langues de l'interface
- 17
- Prix
- Gratuit
À propos
Voice Master injecte un panneau déplaçable dans la page courante, lance la Web Speech API de Chrome et insère le texte reconnu à la position du curseur dans le dernier champ ayant eu le focus : input, textarea, contenteditable ou TinyMCE. Par-dessus, on trouve un panneau de dictée avec sa propre zone de texte, des raccourcis clavier, 33 langues de dictée et une interface en 17 langues.
La difficulté est le moteur de reconnaissance lui-même. webkitSpeechRecognition en mode continu met fin aux sessions de lui-même après des pauses et après quelques minutes de fonctionnement, et ne le signale que par onend. L'extension maintient en vie une session de dictée grâce à un chien de garde séparé qui relance un moteur arrêté et redémarre un moteur en marche sur minuteur, en livrant le texte en attente au champ avant chaque redémarrage planifié.
La correction IA facultative envoie les fragments dictés à OpenAI, Anthropic, DeepSeek ou Gemini avec la propre clé de l'utilisateur. Les appels partent du service worker, les résultats sont insérés dans l'ordre de la parole, et un appel en échec se rabat sur le texte brut. Les clés stockées sont chiffrées au repos et ne sont pas transmises au JavaScript de la page.
Voice Master · reconnaissance vocale
ce qui a été résolu
Maintenir la reconnaissance en vie
dans le produitCe que cela apporteUne longue dictée ne s'arrête pas d'elle-même après une pause ou après quelques minutes, et rien de ce qui a été dit juste avant un redémarrage n'est perdu.
- Problème
- La reconnaissance vocale continue de Chrome met fin aux sessions d'elle-même après des pauses et après quelques minutes, sans autre signal fiable que onend. Une longue dictée s'arrête en silence.
- Solution
Une classe Heartbeat séparée interroge l'état du moteur toutes les 100 ms, relance un moteur arrêté après un délai de grâce et redémarre un moteur en marche sur minuteur, et l'hôte livre le texte en attente avant chaque redémarrage planifié.
- Stack
Le moteur s'arrête de lui-même après une pause et la dictée meurtLe chien de garde tourne toutes les 100 ms et relance le moteur après 1.5 s d'arrêt confirmé
Heartbeat.tick() demande à l'hôte si la reconnaissance fonctionne. Quand ce n'est pas le cas, il enregistre l'instant de l'arrêt et attend un délai de grâce de 1.5 s avant d'appeler onRevive(), de sorte qu'un cycle normal d'arrêt/démarrage à l'intérieur de Chrome n'est pas perturbé.
Une seule relance est en cours à la fois. Si onstart n'arrive pas dans les 3 s, la relance en attente est abandonnée et un nouveau délai de grâce de 1.5 s commence avant la tentative suivante. L'indicateur recognizing n'est posé que par onstart via noteEngineUp(), jamais par optimisme, de sorte qu'un start() en échec (InvalidStateError) ne peut pas faire croire au chien de garde que le moteur est actif.
Chrome met fin à une longue session de lui-même, au moment qu'il choisitRafraîchissement toutes les 270 s : livrer le texte en attente, arrêter le moteur, laisser le chien de garde le relancer
Le chien de garde conserve l'heure de début de la sous-session active courante. Après sessionMaxMs (270000 ms, défini dans speech.js), il appelle refreshSession(), qui livre d'abord la transcription finale accumulée et la dernière phrase provisoire dans le champ cible, puis arrête le moteur. end() constate que l'arrêt n'était pas manuel et retourne sans finaliser ; les ticks suivants relancent une session neuve.
L'intervalle est choisi pour tomber avant la limite d'environ cinq minutes que l'auteur a observée dans Chrome (indiquée dans les commentaires du code), de sorte que le redémarrage a lieu à un moment que l'extension contrôle et non à un moment choisi par Chrome.
Le redémarrage automatique ne doit pas se battre contre l'utilisateur ni contre un microphone refuséDes indicateurs séparés manualStop et fatalError conditionnent chaque relance ; seules les erreurs de microphone et d'autorisation l'arrêtent définitivement
onend se déclenche pour toute sorte d'arrêt, de sorte que end() ne peut pas les distinguer seul. L'hôte garde deux indicateurs : manualStop n'est posé que par l'arrêt de l'utilisateur (bouton, raccourci, fermeture du panneau), fatalError uniquement par les erreurs audio-capture et not-allowed. Le chien de garde et reviveRecognition() vérifient les deux et restent inactifs quand l'un est posé ; toute autre erreur, comme no-speech, laisse la session être relancée.
Pour not-allowed, le gestionnaire compare l'heure de l'erreur à l'heure du clic : une erreur dans les 100 ms signifie que l'autorisation est bloquée par une politique, une erreur plus tardive signifie que l'utilisateur a refusé la demande, et le panneau affiche un message différent pour chaque cas.
Logique de redémarrage emmêlée avec la gestion brute de la Web Speech APIUn mince wrapper RecognitionEngine émet des callbacks ; tout l'état de redémarrage reste dans un seul hôte
RecognitionEngine ne possède que l'objet webkitSpeechRecognition (continu, résultats provisoires, 5 alternatives) et analyse les résultats en finalText, interimText et hadFinal. start() laisse volontairement se propager InvalidStateError pour que l'appelant décide de réessayer ou non.
Les indicateurs, l'accumulation et les décisions de redémarrage vivent dans speech.js, et la politique de temporisation vit dans Heartbeat. Les trois parties communiquent par de petites interfaces de callbacks (isRecognizing, onRevive, onRefresh, onSilence), de sorte que le chien de garde peut être réglé sans toucher au code de reconnaissance.
Faire entrer le texte dans n'importe quel champ
dans le produitCe que cela apporteLes mots prononcés atterrissent là où se trouvait le curseur, dans n'importe quelle zone de texte ou éditeur d'un site, et chaque phrase n'est saisie qu'une fois terminée.
- Problème
- Le texte reconnu doit atterrir à la position du curseur du champ dans lequel l'utilisateur écrivait, sur des pages que l'extension ne contrôle pas, pendant que l'utilisateur clique sur le propre panneau de l'extension.
- Solution
Un TextInserter suit le dernier élément éligible ayant eu le focus et utilise la méthode d'insertion que chaque type d'éditeur accepte ; le panneau évite de voler le focus.
- Stack
Les champs diffèrent : input, textarea, contenteditable, TinyMCEUn seul insert() choisit splice, execCommand('insertText') ou mceInsertContent selon le type de cible
Pour input et textarea, la valeur est découpée entre selectionStart et selectionEnd et le curseur est déplacé après le nouveau texte, de sorte qu'un fragment sélectionné est remplacé par le fragment dicté. Pour contenteditable, il utilise execCommand('insertText'), qui remplace aussi la sélection courante et laisse l'éditeur voir une modification normale. Pour TinyMCE, il appelle la propre commande mceInsertContent de l'éditeur.
TinyMCE s'initialise tard ; le hook de focus qui le concerne est donc attaché après un délai et, si rien d'autre n'avait encore le focus, l'éditeur actif reçoit le focus et est suivi.
Cliquer sur le panneau éloigne le focus du champ cibleSuivre le focus en phase de capture, garder le dernier champ éligible ; empêcher le vol de focus sur les contrôles
Un écouteur de focus en phase de capture sur document enregistre chaque élément éligible dans une liste où le plus récent est en dernier, de sorte que lorsque l'utilisateur clique sur le panneau, la cible d'insertion reste le dernier champ où il se trouvait.
Le bouton de démarrage annule mousedown et les sélecteurs de langue annulent mouseup, de sorte que les presser ne fait pas perdre le focus au champ. Le panneau déplaçable ignore la gestion du glissement sur les champs, boutons et liens afin qu'ils gardent un comportement normal de focus et de clic.
Les résultats provisoires changent sans cesse et ne doivent pas être saisis trop tôtAfficher le texte provisoire dans un aperçu ; ne livrer la phrase que lorsque le flux provisoire se vide
Les résultats finaux s'accumulent dans finalTranscript. Le texte provisoire va dans un panneau d'aperçu déplaçable distinct et est mémorisé comme prev_text ; quand une mise à jour arrive avec une partie provisoire vide, buffer() livre la phrase mémorisée au champ et efface l'aperçu.
Le span final n'est redessiné que lorsqu'un nouveau résultat final est arrivé, ce qui limite le travail sur le DOM pendant une longue dictée.
Chaîne de correction IA
dans le produitCe que cela apporteUne IA au choix de l'utilisateur peut corriger les mots mal entendus d'après le contexte. Le texte garde l'ordre de la parole, et si l'IA échoue, le texte brut arrive quand même.
- Problème
- Quatre fournisseurs d'IA aux API différentes, appelés depuis des pages web quelconques ayant leur propre CSP, tandis que la dictée continue de produire de nouveaux fragments qui doivent rester dans l'ordre.
- Solution
Des adaptateurs de fournisseurs s'exécutent dans le service worker ; la page leur parle par un pont d'événements corrélés, et une file FIFO avec délai d'expiration et repli sur le texte brut alimente le champ.
- Stack
La CSP et CORS de la page bloquent les appels aux API d'IA depuis la pageTous les appels d'API s'exécutent dans le service worker MV3 avec host_permissions pour les quatre hôtes
Le correcteur côté page ne construit que le prompt. La requête transite par le content script jusqu'au service worker, qui possède le fetch. host_permissions dans manifest.json couvre api.openai.com, api.anthropic.com, api.deepseek.com et generativelanguage.googleapis.com.
L'écouteur onMessage du worker renvoie true pour garder le canal ouvert pour la réponse asynchrone.
Les quatre fournisseurs diffèrent par l'authentification et par la forme des requêtes et des réponsesTable d'adaptateurs : par fournisseur une URL, un constructeur d'en-têtes, un constructeur de corps, un analyseur et un lecteur d'erreurs
AI_PROVIDERS décrit chaque API de façon déclarative : en-tête Bearer pour OpenAI et DeepSeek, x-api-key plus en-têtes de version pour Anthropic, x-goog-api-key pour Gemini, et le chemin vers le texte dans chaque réponse. aiCorrect() est identique pour tous.
Une clé vide renvoie le code 'no_key' au lieu d'une erreur, ce qui permet aux appelants de se rabattre silencieusement sur le texte brut. Gemini a été ajouté comme quatrième fournisseur par une entrée de table plus une permission d'hôte dans le manifeste.
Des réponses d'IA asynchrones pourraient insérer des fragments dans le désordre de la paroleFile FIFO en chaîne de promesses, délai d'expiration de 20 s par fragment, texte brut inséré en cas d'échec
LiveImprover.push() ajoute chaque fragment à une seule chaîne de promesses, de sorte que le fragment N+1 n'est envoyé qu'après l'insertion du fragment N. Chaque appel est mis en course avec un délai d'expiration de 20 s. En cas d'échec ou d'expiration, le texte d'origine est inséré, de sorte que la parole dictée n'est jamais perdue.
Les modèles renvoient du texte débarrassé de ses espaces ; keepSpacing() restaure donc les espaces initiaux et finaux d'origine du fragment pour éviter que des fragments consécutifs se collent. Sans clé, la file signale le code 'no_key' et l'interface reste silencieuse, comme si le mode direct était désactivé.
Le monde de la page et le monde de l'extension ne peuvent échanger que des événementsDes ID de requête et une table de promesses en attente transforment les événements de window en appels asynchrones
Le code du panneau s'exécute comme script module dans la page et n'a pas d'API chrome.*. GptCorrector.improve() attribue un identifiant de séquence, stocke le résolveur dans une Map et envoie stt_ai_correct_request. Le content script le transmet au worker et répond par stt_ai_correct_response portant le même identifiant, ce qui résout exactement une promesse en attente.
Le moteur de parole comprend mal certains mots ; un simple correcteur orthographique les laisseUn prompt localisé dit au modèle que le texte est du STT brut et de corriger les mots d'après le contexte
Le prompt corr_and_return indique que le texte provient d'un reconnaisseur vocal imparfait, demande au modèle de remplacer les mots mal reconnus par ce que le locuteur voulait le plus probablement dire, de conserver la langue et le sens, de ne rien ajouter et de ne renvoyer que le texte. Il est traduit dans chaque locale de l'interface.
Dans le panneau de dictée, le texte amélioré remplace le contenu de la zone de texte via execCommand('insertText'), de sorte que Ctrl+Z restaure la version d'origine.
Gestion des clés d'API
shipped · 2026Ce que cela apporteLes propres clés d'IA de l'utilisateur sont stockées chiffrées, et les sites web sur lesquels le panneau apparaît ne peuvent pas les lire. Les réglages restent identiques dans tous les onglets ouverts.
- Problème
- Les clés d'API de l'utilisateur doivent être stockées par une extension dont l'interface s'exécute dans des pages non fiables, de sorte que tout script de page pourrait lire ce que le panneau peut lire.
- Solution
Les clés sont chiffrées et déchiffrées dans le service worker, ne sont en clair que dans la mémoire du content script, et la page ne reçoit que des indicateurs et des indices masqués.
- Stack
Les clés d'API ne doivent pas rester en clair dans le stockage de l'extensionAES-GCM avec un secret aléatoire de 256 bits par installation et un IV neuf de 12 octets par valeur
sttCryptoKey() génère le secret une fois, le stocke et l'importe comme CryptoKey non extractible. Les valeurs chiffrées portent un préfixe de version (stt1:) suivi de l'IV et du texte chiffré en base64.
Le chiffrement s'exécute dans le service worker, car celui-ci est toujours un contexte sécurisé avec crypto.subtle, ce qui n'est pas le cas d'un content script sur une page http simple. Le commentaire du code est explicite : il s'agit d'une protection au repos contre une lecture occasionnelle, pas contre quelqu'un qui détient tout le profil du navigateur.
Les scripts de page ne doivent pas pouvoir lire les clés stockéesLes événements de page ne portent que des indicateurs hasKeys et des indices masqués ; le texte en clair reste dans le content script
sttPublicCfg() est la seule forme qui passe dans la page : fournisseur, indicateur du mode direct, un booléen par fournisseur et un indice comme les 3 premiers et les 4 derniers caractères. Le formulaire de réglages ne reçoit jamais la clé stockée ; un champ intact à l'enregistrement signifie conserver la clé stockée (indicateur keyTouched).
Des utilisateurs existants avaient des clés en clair dans un ancien format de stockageÀ la première lecture, les clés anciennes et en clair sont réenregistrées chiffrées sans action de l'utilisateur
sttGetAi() déplace l'ancien gptApiKey unique vers la structure par fournisseur, chiffre toute valeur sans le préfixe stt1:, réécrit le résultat et supprime l'ancienne cellule. Si le worker n'arrive pas à chiffrer, le texte en clair est conservé pour que l'utilisateur ne soit pas bloqué, et la migration est retentée à la lecture suivante.
sttDecrypt() renvoie une chaîne vide pour une valeur corrompue ou étrangère, de sorte qu'un enregistrement cassé se comporte comme « pas de clé » au lieu de lever une exception.
Les réglages doivent rester à jour dans tous les onglets ouvertsstorage.onChanged rediffuse la configuration nettoyée au miroir de page de chaque onglet
Le panneau de chaque onglet garde un miroir local du fournisseur, de l'indicateur du mode direct et de hasKeys. content.js écoute chrome.storage.onChanged pour l'enregistrement sttAi et pousse dans la page une configuration nettoyée à jour, de sorte que liveEnabled() n'agit jamais sur une clé supprimée ailleurs.
Supprimer une clé dans le popover reste local jusqu'à l'enregistrement ; fermer sans enregistrer conserve la clé stockée.
Fonctionner dans n'importe quelle page
dans le produitCe que cela apporteLe raccourci clavier fonctionne même dans des onglets ouverts avant le chargement de l'extension, et le panneau parle la langue de l'utilisateur sur n'importe quel site.
- Problème
- Le panneau vit à l'intérieur de pages tierces : les content scripts peuvent être absents et le code du monde de la page n'a pas d'API chrome.*.
- Solution
Injection à la demande avec un chemin de repli et un pont de localisation vers le stockage de la page.
- Stack
Raccourci pressé dans un onglet où le content script n'est pas chargéEssayer sendMessage ; sur lastError, injecter content.js avec chrome.scripting, puis renvoyer
start() dans le service worker envoie la bascule à l'onglet. Si chrome.runtime.lastError montre que personne n'écoute, il injecte content.js et répète le message. Le module du panneau et la feuille de style sont ensuite injectés à la demande, en ignorant les pages chrome://.
Le popup fait la même vérification avec un ping isLoaded avant de décider s'il faut écrire à l'onglet ou passer par le worker.
Le code du panneau dans le monde de la page ne peut pas appeler chrome.i18nLe content script copie chaque message dans le localStorage de la page ; la locale en fournit la liste des clés
getLocalizationKeys() lit la liste des clés dans le messages.json de la locale par défaut, qui contient l'ensemble complet, puis stocke chrome.i18n.getMessage(key) pour chacune sous un préfixe stt_. Helper.getI18nMessage() dans la page lit à cet endroit.
Comme la liste des clés vient de en et les valeurs de chrome.i18n, une locale à clés manquantes se rabat sur l'anglais clé par clé. La locale hi manque actuellement de 7 clés et est couverte de cette façon.
Chiffres
du produit- Utilisateurs environ 2,000 Chrome Web Store.
- Langues de l'interface 17
- Langues de dictée 33 63 variantes régionales.