Language

Get in touch

Chrome extension · since 2024

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
  • JavaScript (ES modules)
  • Chrome Extensions Manifest V3
  • Web Speech API (webkitSpeechRecognition)
  • Web Crypto API (AES-GCM)
  • chrome.storage / chrome.scripting / chrome.i18n
  • OpenAI, Anthropic, DeepSeek and Gemini HTTP APIs
Chrome Web Store chromewebstore.google.com

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

LIVEsince 2024

Keeping recognition alive

in the product

What 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
  • Web Speech API
  • JavaScript
Tasks · 4
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 product

What 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
  • DOM
  • Selection API
  • document.execCommand
  • TinyMCE API
Tasks · 3
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 product

What 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
  • Service worker
  • fetch
  • CustomEvent
  • Promises
Tasks · 5
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 · 2026

What 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
  • Web Crypto API (AES-GCM)
  • chrome.storage.local
  • Service worker
Tasks · 4
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 product

What 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
  • chrome.scripting
  • chrome.i18n
Tasks · 2
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.