Voice Master
Розширення для Chrome, що вводить надиктований текст у будь-яке текстове поле на будь-якій веб-сторінці, з необов'язковим AI-виправленням через власний API-ключ користувача.
- Випущено
- Користувачі
- приблизно 2 000
- Мови інтерфейсу
- 17
- Ціна
- Безкоштовно
Про мене
Voice Master вбудовує на поточну сторінку панель, яку можна перетягувати, запускає Web Speech API Chrome і вставляє розпізнаний текст у позицію курсора в останньому полі, що мало фокус: input, textarea, contenteditable або TinyMCE. Поверх цього є панель диктування з власним текстовим полем, гарячі клавіші, 33 мови диктування та інтерфейс 17 мовами.
Найскладніше — сам рушій розпізнавання. webkitSpeechRecognition у безперервному режимі сам завершує сесії після пауз і після кількох хвилин роботи та повідомляє про це лише через onend. Розширення підтримує сесію диктування живою окремим сторожовим таймером, який оживляє зупинений рушій і за таймером перезапускає працюючий, доставляючи очікуваний текст у поле перед кожним плановим перезапуском.
Необов'язкове AI-виправлення надсилає надиктовані фрагменти в OpenAI, Anthropic, DeepSeek або Gemini з власним ключем користувача. Виклики виходять із service worker, результати вставляються в порядку мовлення, а невдалий виклик повертається до сирого тексту. Збережені ключі шифруються у стані спокою й не передаються JavaScript сторінки.
Voice Master · мовлення в текст
що розв'язано
Підтримка розпізнавання живим
у продуктіЩо це даєДовге диктування не зупиняється само після паузи чи через кілька хвилин, і нічого з сказаного безпосередньо перед перезапуском не губиться.
- Проблема
- Безперервне розпізнавання мовлення в Chrome саме завершує сесії після пауз і після кількох хвилин, без надійного сигналу, крім onend. Довге диктування мовчки зупиняється.
- Рішення
Окремий клас Heartbeat опитує стан рушія кожні 100 мс, оживляє зупинений рушій після пільгового періоду й за таймером перезапускає працюючий, а хост доставляє очікуваний текст перед кожним плановим перезапуском.
- Стек
Рушій сам зупиняється після паузи, і диктування завмираєСторожовий таймер спрацьовує кожні 100 мс і перезапускає рушій після 1.5 с підтвердженого простою
Heartbeat.tick() питає хост, чи працює розпізнавання. Коли ні, він фіксує момент зупинки і чекає 1.5 с пільгового періоду, перш ніж викликати onRevive(), тож звичайний цикл stop/start усередині Chrome не порушується.
Одночасно виконується лише одне оживлення. Якщо onstart не надходить протягом 3 с, очікуване оживлення відкидається і перед наступною спробою починається новий пільговий період 1.5 с. Прапорець recognizing встановлюється лише через onstart за допомогою noteEngineUp(), ніколи не оптимістично, тож невдалий start() (InvalidStateError) не може змусити сторожовий таймер вважати, що рушій працює.
Chrome сам завершує довгу сесію в момент, який обирає самОновлення кожні 270 с: доставити очікуваний текст, зупинити рушій, дати сторожовому таймеру його оживити
Сторожовий таймер зберігає час початку поточної живої підсесії. Після sessionMaxMs (270000 мс, задано в speech.js) він викликає refreshSession(), яка спершу доставляє накопичену фінальну розшифровку й останню проміжну фразу в цільове поле, а потім зупиняє рушій. end() бачить, що зупинка не була ручною, і повертається без завершення; наступні тики оживляють нову сесію.
Інтервал вибрано так, щоб він припадав раніше за приблизно п'ятихвилинний ліміт, який спостерігав автор у Chrome (зазначено в коментарях коду), тож перезапуск відбувається в момент, який контролює розширення, а не в той, який обирає Chrome.
Автоперезапуск не повинен боротися з користувачем чи відхиленим мікрофономОкремі прапорці manualStop і fatalError керують кожним оживленням; остаточно зупиняють лише помилки мікрофона й дозволів
onend спрацьовує на будь-яку зупинку, тож end() сам не може їх розрізнити. Хост тримає два прапорці: manualStop встановлюється лише зупинкою користувача (кнопка, гаряча клавіша, закриття панелі), fatalError — лише помилками audio-capture і not-allowed. Сторожовий таймер і reviveRecognition() перевіряють обидва й простоюють, коли встановлено будь-який; кожна інша помилка, як-от no-speech, залишає сесію для оживлення.
Для not-allowed обробник порівнює час помилки з часом кліку: помилка протягом 100 мс означає, що дозвіл заблоковано політикою, пізніша — що користувач відхилив запит, і панель показує різне повідомлення для кожного випадку.
Логіка перезапуску сплутана з сирою обробкою Web Speech APIТонка обгортка RecognitionEngine викликає колбеки; увесь стан перезапуску лишається в одному хості
RecognitionEngine володіє лише об'єктом webkitSpeechRecognition (continuous, проміжні результати, 5 альтернатив) і розбирає результати на finalText, interimText і hadFinal. start() навмисно дозволяє InvalidStateError поширюватися, щоб викликач вирішував, чи повторювати спробу.
Прапорці, накопичення та рішення про перезапуск живуть у speech.js, а політика таймінгів — у Heartbeat. Ці три частини спілкуються через малі інтерфейси колбеків (isRecognizing, onRevive, onRefresh, onSilence), тож сторожовий таймер можна налаштовувати, не чіпаючи код розпізнавання.
Введення тексту в будь-яке поле
у продуктіЩо це даєВимовлені слова потрапляють туди, де стояв курсор, у будь-яке текстове поле чи редактор на сайті, і кожна фраза вводиться лише тоді, коли закінчена.
- Проблема
- Розпізнаний текст має потрапити в позицію курсора в тому полі, де друкував користувач, на сторінках, які розширення не контролює, поки користувач клікає власну панель розширення.
- Рішення
TextInserter відстежує останній придатний елемент із фокусом і використовує спосіб вставки, який приймає кожен тип редактора; панель не краде фокус.
- Стек
Поля різняться: input, textarea, contenteditable, TinyMCEОдин insert() вибирає splice, execCommand('insertText') або mceInsertContent залежно від типу цілі
Для input і textarea значення зрощується між selectionStart і selectionEnd, а курсор переміщується за новий текст, тож виділений фрагмент замінюється надиктованим. Для contenteditable використовується execCommand('insertText'), що теж замінює поточне виділення і дає редактору побачити звичайну правку. Для TinyMCE викликається власна команда редактора mceInsertContent.
TinyMCE ініціалізується пізно, тож хук фокуса для нього прикріплюється із затримкою, і якщо ще нічого не мало фокусу, активний редактор отримує фокус і відстежується.
Клік по панелі забирає фокус із цільового поляВідстежувати фокус у фазі capture, зберігати останнє придатне поле; блокувати крадіжку фокуса елементами керування
Слухач фокуса у фазі capture на document записує кожен придатний елемент у список з найновішим останнім, тож коли користувач клікає панель, ціллю вставки лишається останнє поле, в якому він був.
Кнопка старту скасовує mousedown, а вибір мови скасовує mouseup, тож їх натискання не знімає фокус із поля. Панель, яку можна перетягувати, пропускає обробку перетягування на полях введення, кнопках і посиланнях, щоб вони зберігали звичайну поведінку фокуса й кліку.
Проміжні результати постійно змінюються й не повинні вводитися завчасноПоказувати проміжний текст у попередньому перегляді; доставляти фразу, лише коли потік проміжних результатів спорожніє
Фінальні результати накопичуються у finalTranscript. Проміжний текст іде в окрему панель попереднього перегляду, яку можна перетягувати, і запам'ятовується як prev_text; коли надходить оновлення з порожньою проміжною частиною, buffer() доставляє запам'ятовану фразу в поле й очищає попередній перегляд.
Фінальний span перемальовується лише тоді, коли надійшов новий фінальний результат, що зменшує роботу з DOM під час довгого диктування.
Конвеєр AI-виправлення
у продуктіЩо це даєAI на вибір користувача може виправляти неправильно почуті слова за контекстом. Текст зберігає порядок мовлення, а якщо AI збоїть, сирий текст усе одно надходить.
- Проблема
- Чотири AI-провайдери з різними API, що викликаються зсередини довільних веб-сторінок з власним CSP, поки диктування продовжує видавати нові фрагменти, які мають лишатися по порядку.
- Рішення
Адаптери провайдерів працюють у service worker; сторінка спілкується з ними через кореловану шину подій, а черга FIFO з тайм-аутом і поверненням до сирого тексту живить поле.
- Стек
CSP і CORS сторінки блокують виклики до AI API зі сторінкиУсі виклики API виконуються в service worker MV3 з host_permissions для чотирьох хостів
Коректор на боці сторінки лише будує промпт. Запит подорожує через content script до service worker, який володіє fetch. host_permissions у manifest.json охоплюють api.openai.com, api.anthropic.com, api.deepseek.com і generativelanguage.googleapis.com.
Слухач onMessage воркера повертає true, щоб тримати канал відкритим для асинхронної відповіді.
Чотири провайдери відрізняються автентифікацією, формами запиту й відповідіТаблиця адаптерів: для кожного провайдера URL, конструктор заголовків, конструктор тіла, парсер і читач помилок
AI_PROVIDERS декларативно описує кожен API: заголовок Bearer для OpenAI і DeepSeek, x-api-key плюс заголовки версії для Anthropic, x-goog-api-key для Gemini та шлях до тексту в кожній відповіді. aiCorrect() однакова для всіх.
Порожній ключ повертає код 'no_key' замість помилки, що дає викликачам змогу мовчки повернутися до сирого тексту. Gemini додано як четвертого провайдера одним записом у таблиці плюс дозволом хоста в маніфесті.
Асинхронні відповіді AI могли вставити фрагменти не в порядку мовленняЧерга FIFO на ланцюжку промісів, тайм-аут 20 с на фрагмент, сирий текст вставляється при будь-якому збої
LiveImprover.push() додає кожен фрагмент в один ланцюжок промісів, тож фрагмент N+1 надсилається лише після того, як фрагмент N вставлено. Кожен виклик змагається з тайм-аутом 20 с. При збої чи тайм-ауті вставляється оригінальний текст, тож надиктоване мовлення ніколи не губиться.
Моделі повертають обрізаний текст, тож keepSpacing() відновлює початкові пробіли на початку й в кінці фрагмента, щоб послідовні фрагменти не склеювалися. Без ключа черга повідомляє код 'no_key', а інтерфейс мовчить, наче живий режим вимкнено.
Світ сторінки й світ розширення можуть обмінюватися лише подіямиID запитів і мапа очікуваних промісів перетворюють події window на асинхронні виклики
Код панелі працює як модульний скрипт на сторінці й не має API chrome.*. GptCorrector.improve() призначає порядковий id, зберігає resolver у Map і надсилає stt_ai_correct_request. Content script пересилає його воркеру й відповідає stt_ai_correct_response з тим самим id, що розв'язує рівно один очікуваний проміс.
Рушій мовлення чує слова неправильно; звичайна перевірка правопису їх не виправляєЛокалізований промпт каже моделі, що текст — сирий STT, і виправляти слова за контекстом
Промпт corr_and_return стверджує, що текст надійшов від недосконалого розпізнавача мовлення, просить модель замінити неправильно розпізнані слова тим, що мовець найімовірніше мав на увазі, зберегти мову й зміст, нічого не додавати й повернути лише текст. Він перекладений на кожну локаль інтерфейсу.
У панелі диктування покращений текст замінює вміст textarea через execCommand('insertText'), тож Ctrl+Z відновлює оригінальну версію.
Робота з API-ключами
shipped · 2026Що це даєВласні AI-ключі користувача зберігаються зашифрованими, а сайти, на яких з'являється панель, не можуть їх прочитати. Налаштування однакові в кожній відкритій вкладці.
- Проблема
- Розширення, чий інтерфейс працює всередині недовірених сторінок, має зберігати API-ключі користувача, тож будь-який скрипт сторінки міг би прочитати те, що може прочитати панель.
- Рішення
Ключі шифруються й розшифровуються в service worker, у відкритому вигляді лишаються лише в пам'яті content script, а сторінка отримує тільки прапорці та замасковані підказки.
- Стек
API-ключі не повинні лежати у відкритому тексті в сховищі розширенняAES-GCM із випадковим 256-бітним секретом на встановлення та новим 12-байтовим IV на значення
sttCryptoKey() генерує секрет один раз, зберігає його й імпортує як неекспортований CryptoKey. Зашифровані значення несуть префікс версії (stt1:), за яким ідуть IV і шифротекст у base64.
Криптографія виконується в service worker, бо він завжди є захищеним контекстом із crypto.subtle, а content script на звичайній http-сторінці — ні. Коментар у коді прямо каже, що це захист у стані спокою від випадкового прочитання, а не від того, хто має весь профіль браузера.
Скрипти сторінки не повинні мати змоги читати збережені ключіПодії сторінки несуть лише прапорці hasKeys і замасковані підказки; відкритий текст лишається в content script
sttPublicCfg() — єдина форма, що перетинає межу в сторінку: провайдер, живий прапорець, булеве значення на провайдера й підказка на кшталт перших 3 та останніх 4 символів. Форма налаштувань ніколи не отримує збережений ключ; нечіпане поле при збереженні означає залишити збережений ключ (прапорець keyTouched).
У наявних користувачів були ключі у відкритому тексті в старому форматі сховищаПри першому читанні застарілі й відкриті ключі повторно зберігаються зашифрованими без дій користувача
sttGetAi() переносить старий єдиний gptApiKey у структуру на провайдера, шифрує будь-яке значення без префікса stt1:, записує результат назад і видаляє застарілу комірку. Якщо воркер не зміг зашифрувати, відкритий текст зберігається, щоб користувача не заблокувало, а міграція повторюється при наступному читанні.
sttDecrypt() повертає порожній рядок для пошкодженого або чужого значення, тож зламаний запис поводиться як 'немає ключа', а не кидає виняток.
Налаштування мають лишатися актуальними в кожній відкритій вкладціstorage.onChanged повторно розсилає санітизовану конфігурацію в дзеркало сторінки кожної вкладки
Панель кожної вкладки тримає локальне дзеркало провайдера, живого прапорця й hasKeys. content.js слухає chrome.storage.onChanged для запису sttAi і надсилає на сторінку свіжу санітизовану конфігурацію, тож liveEnabled() ніколи не діє за ключем, видаленим деінде.
Видалення ключа у спливному вікні локальне до Save; закриття без збереження зберігає збережений ключ.
Робота всередині будь-якої сторінки
у продуктіЩо це даєГаряча клавіша працює навіть у вкладках, відкритих до завантаження розширення, а панель говорить мовою користувача на будь-якому сайті.
- Проблема
- Панель живе всередині сторонніх сторінок: content scripts можуть бути відсутні, а код у світі сторінки не має API chrome.*.
- Рішення
Ін'єкція на вимогу із запасним шляхом і міст локалізації в сховище сторінки.
- Стек
Гарячу клавішу натиснуто у вкладці, де content script не завантаженийСпробувати sendMessage; при lastError ін'єктувати content.js через chrome.scripting, потім надіслати повторно
start() у service worker надсилає перемикання у вкладку. Якщо chrome.runtime.lastError показує, що ніхто не слухає, він ін'єктує content.js і повторює повідомлення. Модуль панелі та таблиця стилів потім ін'єктуються на запит, пропускаючи сторінки chrome://.
Popup перевіряє так само за допомогою ping isLoaded, перш ніж вирішити, надсилати повідомлення вкладці чи йти через воркер.
Код панелі у світі сторінки не може викликати chrome.i18nContent script копіює кожне повідомлення в localStorage сторінки; en постачає список ключів
getLocalizationKeys() читає список ключів із messages.json локалі за замовчуванням, у якій повний набір, потім зберігає chrome.i18n.getMessage(key) для кожного під префіксом stt_. Helper.getI18nMessage() на сторінці читає звідти.
Оскільки список ключів береться з en, а значення — з chrome.i18n, локаль із відсутніми ключами повертається до англійської по ключах. Локаль hi наразі не має 7 ключів і покривається таким чином.
Цифри
з продукту- Користувачі близько 2,000 Chrome Web Store.
- Мови інтерфейсу 17
- Мови диктування 33 63 регіональні варіанти.