Voice Master
Chrome extension that types dictated speech into any text field on any web page, with optional AI clean-up through the user's own API key.
- Released
- Users
- about 2,000
- Interface languages
- 17
- Price
- Free
About
Voice Master injects a draggable panel into the current page, runs Chrome's Web Speech API and inserts recognized text at the caret of the last focused field: input, textarea, contenteditable or TinyMCE. A dictation panel with its own text area, hotkeys, 33 dictation languages and an interface in 17 languages sit on top of that.
The hard part is the recognition engine itself. webkitSpeechRecognition in continuous mode ends sessions on its own after pauses and after a few minutes of work, and it reports this only through onend. The extension keeps a dictation session alive with a separate watchdog that revives a stopped engine and restarts a running one on a timer, delivering pending text to the field before each planned restart.
Optional AI correction sends dictated chunks to OpenAI, Anthropic, DeepSeek or Gemini with the user's own key. Calls go out from the service worker, results are inserted in spoken order, and a failed call falls back to the raw text. Stored keys are encrypted at rest and are not sent to the page's JavaScript.
Voice Master · speech-to-text
what was solved
Keeping recognition alive
in the productWhat it givesLong dictation does not stop on its own after a pause or after a few minutes, and nothing said just before a restart is lost.
- Problem
- Chrome's continuous speech recognition ends sessions by itself after pauses and after a few minutes, with no reliable signal except onend. A long dictation silently stops.
- Solution
A separate Heartbeat class polls the engine state every 100 ms, revives a stopped engine after a grace period and restarts a running one on a timer, and the host delivers pending text before each planned restart.
- Stack
Engine stops on its own after a pause and dictation goes deadWatchdog ticks every 100 ms and restarts the engine after 1.5 s of confirmed downtime
Heartbeat.tick() asks the host whether recognition is running. When it is not, it records the moment of death and waits a 1.5 s grace period before calling onRevive(), so a normal stop/start cycle inside Chrome is not disturbed.
Only one revive is in flight at a time. If onstart does not arrive within 3 s, the pending revive is dropped and a new 1.5 s grace period starts before the next attempt. The recognizing flag is set only by onstart through noteEngineUp(), never optimistically, so a failed start() (InvalidStateError) cannot make the watchdog believe the engine is up.
Chrome ends a long session on its own, at a moment it choosesRefresh every 270 s: deliver pending text, stop the engine, let the watchdog revive it
The watchdog keeps the start time of the current live sub-session. After sessionMaxMs (270000 ms, set in speech.js) it calls refreshSession(), which first delivers the accumulated final transcript and the last interim phrase into the target field, then stops the engine. end() sees that the stop was not manual and returns without finalizing; the next ticks revive a fresh session.
The interval is chosen to land before the roughly five-minute limit the author observed in Chrome (stated in the code comments), so the restart happens at a moment the extension controls instead of one Chrome chooses.
Auto-restart must not fight the user or a denied microphoneSeparate manualStop and fatalError flags gate every revive; only microphone and permission errors stop it for good
onend fires for every kind of stop, so end() cannot tell them apart by itself. The host keeps two flags: manualStop is set only by the user's stop (button, hotkey, panel close), fatalError only by audio-capture and not-allowed errors. The watchdog and reviveRecognition() check both and stay idle when either is set; every other error, such as no-speech, leaves the session to be revived.
For not-allowed the handler compares the error time with the click time: an error within 100 ms means the permission is blocked by policy, a later one means the user denied the prompt, and the panel shows a different message for each.
Restart logic tangled with raw Web Speech API handlingThin RecognitionEngine wrapper emits callbacks; all restart state stays in one host
RecognitionEngine owns only the webkitSpeechRecognition object (continuous, interim results, 5 alternatives) and parses results into finalText, interimText and hadFinal. start() deliberately lets InvalidStateError propagate so the caller decides whether to retry.
Flags, accumulation and restart decisions live in speech.js, and the timing policy lives in Heartbeat. The three parts talk through small callback interfaces (isRecognizing, onRevive, onRefresh, onSilence), so the watchdog can be tuned without touching recognition code.
Getting text into any field
in the productWhat it givesSpoken words land where the cursor was, in any text box or editor on a site, and each phrase is typed only once it is finished.
- Problem
- Recognized text must land at the caret of whatever field the user was typing in, on pages the extension does not control, while the user clicks the extension's own panel.
- Solution
A TextInserter tracks the last eligible focused element and uses the insertion method each editor type accepts; the panel avoids stealing focus.
- Stack
Fields differ: input, textarea, contenteditable, TinyMCEOne insert() picks splice, execCommand('insertText') or mceInsertContent per target type
For input and textarea the value is spliced between selectionStart and selectionEnd and the caret is moved past the new text, so a selected fragment is replaced by the dictated one. For contenteditable it uses execCommand('insertText'), which also replaces the current selection and lets the editor see a normal edit. For TinyMCE it calls the editor's own mceInsertContent command.
TinyMCE initializes late, so the focus hook for it is attached after a delay and, if nothing else was focused yet, the active editor is focused and tracked.
Clicking the panel moves focus away from the target fieldTrack focus in capture phase, keep the last eligible field; block focus theft on controls
A capture-phase focus listener on document records every eligible element in a most-recent-last list, so when the user clicks the panel the insertion target is still the last field they were in.
The start button cancels mousedown and the language selects cancel mouseup, so pressing them does not blur the field. The draggable panel skips drag handling on inputs, buttons and links so they keep normal focus and click behaviour.
Interim results change constantly and must not be typed earlyShow interim text in a preview; deliver the phrase only when the interim stream empties
Final results accumulate in finalTranscript. Interim text goes to a separate draggable preview panel and is remembered as prev_text; when an update arrives with an empty interim part, buffer() delivers the remembered phrase to the field and clears the preview.
The final span is re-rendered only when a new final result arrived, which keeps DOM work low during long dictation.
AI correction pipeline
in the productWhat it givesAn AI of the user's choice can fix misheard words by context. Text keeps its spoken order, and if the AI fails, the raw text still arrives.
- Problem
- Four AI providers with different APIs, called from inside arbitrary web pages with their own CSP, while dictation keeps producing new chunks that must stay in order.
- Solution
Provider adapters run in the service worker; the page talks to them through a correlated event bridge, and a FIFO queue with timeout and raw fallback feeds the field.
- Stack
Page CSP and CORS block calls to AI APIs from the pageAll API calls run in the MV3 service worker with host_permissions for the four hosts
The page-side corrector only builds the prompt. The request travels through the content script to the service worker, which owns the fetch. host_permissions in manifest.json cover api.openai.com, api.anthropic.com, api.deepseek.com and generativelanguage.googleapis.com.
The worker's onMessage listener returns true to keep the channel open for the async response.
Four providers differ in auth, request and response shapesAdapter table: per provider a URL, header builder, body builder, parser and error reader
AI_PROVIDERS describes each API declaratively: Bearer header for OpenAI and DeepSeek, x-api-key plus version headers for Anthropic, x-goog-api-key for Gemini, and the path to the text in each response. aiCorrect() is the same for all of them.
An empty key returns code 'no_key' instead of an error, which lets callers fall back to raw text silently. Gemini was added as a fourth provider by one table entry plus a manifest host permission.
Async AI replies could insert chunks out of spoken orderPromise-chain FIFO queue, 20 s timeout per chunk, raw text inserted on any failure
LiveImprover.push() appends each chunk to a single promise chain, so chunk N+1 is sent only after chunk N was inserted. Each call races a 20 s timeout. On failure or timeout the original text is inserted, so dictated speech is never lost.
Models return trimmed text, so keepSpacing() restores the chunk's original leading and trailing whitespace to keep consecutive chunks from gluing together. With no key the queue reports code 'no_key' and the UI stays silent, as if live mode were off.
Page world and extension world can only exchange eventsRequest IDs and a pending-promise map turn window events into async calls
The panel code runs as a module script in the page and has no chrome.* APIs. GptCorrector.improve() assigns a sequence id, stores the resolver in a Map and dispatches stt_ai_correct_request. The content script forwards it to the worker and answers with stt_ai_correct_response carrying the same id, which resolves exactly one pending promise.
Speech engine mishears words; a plain spellcheck leaves themLocalized prompt tells the model the text is raw STT and to fix words by context
The corr_and_return prompt states that the text came from an imperfect speech recognizer, asks the model to replace misrecognized words with what the speaker most likely meant, keep the language and meaning, add nothing and return only the text. It is translated into every UI locale.
In the dictation panel the improved text replaces the textarea content through execCommand('insertText'), so Ctrl+Z restores the original version.
API key handling
shipped · 2026What it givesThe user's own AI keys are stored encrypted, and the websites where the panel appears cannot read them. Settings stay the same in every open tab.
- Problem
- User API keys must be stored by an extension whose UI runs inside untrusted pages, so any page script could read what the panel can read.
- Solution
Keys are encrypted and decrypted in the service worker, held in plaintext only in the content script's memory, and the page receives only flags and masked hints.
- Stack
API keys must not sit in plain text in extension storageAES-GCM with a random 256-bit per-install secret and a fresh 12-byte IV per value
sttCryptoKey() generates the secret once, stores it and imports it as a non-extractable CryptoKey. Encrypted values carry a version prefix (stt1:) followed by IV and ciphertext in base64.
Crypto runs in the service worker because it is always a secure context with crypto.subtle, while a content script on a plain http page is not. The code comment is explicit that this is protection at rest against casual reading, not against someone who has the whole browser profile.
Page scripts must not be able to read stored keysPage events carry only hasKeys flags and masked hints; plaintext stays in content script
sttPublicCfg() is the only shape that crosses into the page: provider, live flag, a boolean per provider and a hint like the first 3 and last 4 characters. The settings form never receives the stored key; an untouched field on Save means keep the stored key (keyTouched flag).
Existing users had plaintext keys in an old storage formatOn first read, legacy and plaintext keys are re-saved encrypted without user action
sttGetAi() moves the old single gptApiKey into the per-provider structure, encrypts any value without the stt1: prefix, writes the result back and removes the legacy cell. If the worker fails to encrypt, the plaintext is kept so the user is not locked out, and migration is retried on the next read.
sttDecrypt() returns an empty string on a corrupted or foreign value, so a broken record behaves as 'no key' instead of throwing.
Settings must stay current in every open tabstorage.onChanged re-broadcasts the sanitized config to every tab's page mirror
Each tab's panel keeps a local mirror of provider, live flag and hasKeys. content.js listens to chrome.storage.onChanged for the sttAi record and pushes a fresh sanitized config into the page, so liveEnabled() never acts on a key that was deleted elsewhere.
Deleting a key in the popover is local until Save; closing without saving keeps the stored key.
Running inside any page
in the productWhat it givesThe hotkey works even in tabs that were open before the extension loaded, and the panel speaks the user's language on any site.
- Problem
- The panel lives inside third-party pages: content scripts may be missing and page-world code has no chrome.* APIs.
- Solution
On-demand injection with a fallback path and a localization bridge into page storage.
- Stack
Hotkey pressed in a tab where the content script is not loadedTry sendMessage; on lastError inject content.js with chrome.scripting, then resend
start() in the service worker sends the toggle to the tab. If chrome.runtime.lastError shows nobody is listening, it injects content.js and repeats the message. The panel module and stylesheet are then injected on request, skipping chrome:// pages.
The popup checks the same way with an isLoaded ping before deciding whether to message the tab or go through the worker.
Page-world panel code cannot call chrome.i18nContent script copies every message into page localStorage; en supplies the key list
getLocalizationKeys() reads the key list from the default locale's messages.json, which has the complete set, then stores chrome.i18n.getMessage(key) for each under an stt_ prefix. Helper.getI18nMessage() in the page reads from there.
Because the key list comes from en and the values from chrome.i18n, a locale with missing keys falls back to English per key. The hi locale currently lacks 7 keys and is covered this way.
Numbers
from the product- Users about 2,000 Chrome Web Store.
- Interface languages 17
- Dictation languages 33 63 regional variants.