Голосовой ввод в любое окно Windows за секунду. Офлайн, на CPU, без единого гигабайта torch. cpu.. cpu. CTranslate2.. cpu. CTranslate2. faster-whisper.. cpu. CTranslate2. faster-whisper. Open source.. cpu. CTranslate2. faster-whisper. Open source. python.. cpu. CTranslate2. faster-whisper. Open source. python. speech-to-text.. cpu. CTranslate2. faster-whisper. Open source. python. speech-to-text. whisper.. cpu. CTranslate2. faster-whisper. Open source. python. speech-to-text. whisper. WhisperType.. cpu. CTranslate2. faster-whisper. Open source. python. speech-to-text. whisper. WhisperType. windows.. cpu. CTranslate2. faster-whisper. Open source. python. speech-to-text. whisper. WhisperType. windows. распознавание речи.

Существующие решения для диктовки текста меня не устраивали по одной и той же причине: голос уходит на чужой сервер, а за это ещё и просят подписку. Wispr Flow, Blabby, встроенный Win+H — всё либо в облаке, либо работает так, что проще напечатать.

Хотелось простого: нажал хоткей, сказал, нажал ещё раз — текст появился в том поле, где стоял курсор. Хоть в браузере, хоть в IDE, хоть в мессенджере. Локально. Бесплатно. Без прав администратора.

Получился WhisperType — MIT, Windows 10/11 x64, ~250 МБ после установки плюс 1.6 ГБ модели. Актуальная версия — 1.3.1. Ниже — как он устроен внутри, потому что интересная часть здесь не «прикрутил Whisper», а «заставил Whisper на голом CPU отвечать за секунду».

Модель — deepdml/faster-whisper-large-v3-turbo-ct2, то есть large-v3-turbo, сконвертированный в формат CTranslate2, в квантизации int8_float32. Репозиторий модели задаётся параметром конфига: кому нужна точность важнее скорости, ставит Systran/faster-whisper-large-v3 и получает более аккуратную расшифровку за в несколько раз большее время. Все замеры ниже — на turbo, Ryzen 5 7500F (6 ядер / 12 потоков), инференс только на CPU.


Главная проблема: Whisper всегда думает 30 секунд

Это то, обо что спотыкается любая наивная реализация. Энкодер Whisper работает с окном фиксированной длины в 30 секунд. Функция pad_or_trim дополняет мел-спектрограмму нулями до 3000 кадров независимо от того, сколько реально сказано.

Следствие: слово «привет» стоит ровно столько же, сколько фраза на тридцать секунд. На Ryzen 5 7500F с large-v3-turbo в int8_float32 это ~4 секунды на любую реплику. Четыре секунды ожидания после каждого «ок, сделаю» — это не голосовой ввод, это издевательство.

Значит, главный резерв — не гонять энкодер по пустоте.

Сужение окна энкодера

Публичного параметра для смены длины окна в faster-whisper нет. Поэтому — контролируемый monkeypatch внутренней функции, обёрнутый в контекстный менеджер:

@contextlib.contextmanager
def encoder_window(frames):
    original = ftr.pad_or_trim
    ftr.pad_or_trim = lambda array, length=frames, **kw: original(array, length, **kw)
    try:
        yield
    finally:
        ftr.pad_or_trim = original

Ширина окна считается по реальной длине записи:

frames = (длительность + WINDOW_MARGIN_SECONDS) * 100
frames = max(frames, MIN_WINDOW_FRAMES)
frames >= 3000 → None (сужать нечего, обычный полный проход)

Константа

Значение

Смысл

WINDOW_MARGIN_SECONDS

4

запас над длиной фразы

MIN_WINDOW_FRAMES

1200 (12 с)

нижний порог окна

FULL_WINDOW_FRAMES

3000 (30 с)

штатное окно Whisper

Патч живёт под тем же замком, что и сам инференс: подмена глобальной функции библиотеки означает, что два одновременных распознавания перетёрли бы её друг у друга. Это важно, потому что потоковая нарезка (см. ниже) распознаёт куски параллельно с записью.

Результат: ~1 с вместо ~4 с на короткой записи.

Два подводных камня, каждый стоил вечера

Первый: нижний порог 12 секунд. Хотелось сужать агрессивнее — зачем «привету» двенадцать секунд? Затем, что на окне короче ~8 секунд модель начинает зацикливать одиночное слово: «Привет. Привет. Привет.» — от трёх до десяти раз. При этом на 12 секундах короткая фраза всё равно распознаётся за ту же секунду, так что порог не стоит ничего. Оставил.

Второй, куда менее очевидный: узкое окно обязано идти в паре с without_timestamps. Это уже не оптимизация, а условие корректности. С включёнными таймкодами модель на суженном окне начинает «переливать» время за пределы записи: придумывает продолжение, выдаёт дубли и делает повторные проходы по 6–17 секунд — то есть работает медленнее, чем без всякой оптимизации.

without_timestamps = frames is not None   # развязывать нельзя

Таймкоды выключаются ровно тогда, когда окно сужено. На полном окне они нужны — там аудио может быть длиннее 30 секунд, и по ним делается переход между окнами. Приложению таймкоды не нужны в принципе: оно склеивает только текст.

Остаточный самоповтор дешевле схлопнуть, чем перераспознать

Даже с without_timestamps изредка проскакивает повтор. Первая версия решала это повторным распознаванием на полном окне — что возвращало те самые секунды и давало всплески до 7 с на тихой речи.

Текущее решение — схлопывание на уровне текста. SequenceMatcher проверяет, делится ли строка на 2 или 3 почти одинаковые части (ratio > 0.9), и если да — оставляет одну. Пороги: длина ≥ 24 символов, часть ≥ 12 символов, чтобы не съесть осмысленный короткий повтор.

Цена честно записана в docstring: если пользователь действительно дважды подряд произнёс одно и то же длинное предложение, останется одна копия. Событие редкое, выигрыш в скорости — постоянный.


Вторая проблема: длинная диктовка

Сужение окна убирает лишнюю работу, но не отменяет физику: сорок секунд речи — это сорок секунд аудио. Ждать после длинного монолога всё равно придётся.

Решение — распознавать во время записи. Отдельный поток раз в 0.25 с смотрит, накопилось ли chunk_seconds (по умолчанию 8), отрезает префикс и отправляет его в модель. К моменту отпускания хоткея остаётся короткий хвост.

chunk_seconds

Ожидание после записи 20 с

25 (запись не режется вовсе)

4.2 с

8 (дефолт)

1.4 с

Тут тоже две тонкости, каждая из которых была багом.

Резать можно только по настоящей паузе. Разрез посреди слова задваивает его на стыке кусков. find_cut_point бьёт сигнал на фреймы по 100 мс, считает RMS и ищет самый тихий — причём с оглядкой назад на 6 секунд от целевой точки, а не среди самых свежих данных. Если паузы нет и есть запас по времени — функция честно возвращает «резать рано, копим дальше».

Каждый кусок фильтруется от галлюцинаций отдельно, а не после склейки: обрывок на стыке легко порождает «продолжение следует», и в общем тексте её уже не отличить от речи.

Жёсткий предел куска — 25 секунд, а не 29. Это не круглое число, оно вычисляется:

30 (полное окно) − 4 (запас) − 1 = 25

При куске в 26 секунд окно перестаёт сужаться, и кусок уходит дорогим полным проходом с таймкодами — тем самым, ради ухода от которого всё затевалось. Хуже того: пока этот тяжёлый кусок считается в фоне, финальное распознавание хвоста ждёт освобождения общего замка модели. Пользователь видит зависший индикатор.

Ровно это и происходило до версии 1.1.2, где hard_max был 29. Теперь инвариант зафиксирован тестом, чтобы не вернулось.


Третья проблема: сколько дать потоков

Замеры на Ryzen 5 7500F (6 ядер / 12 потоков), turbo, лучшее из 3 прогонов:

Потоков

«привет»

фраза 5.5 с

диктовка 15 с

2

2.17 с

1.96 с

3.58 с

4

1.19 с

1.37 с

2.43 с

6 (= физическим ядрам)

1.23 с

1.37 с

2.54 с

8

1.07 с

1.22 с

2.23 с

10 (автоподбор)

0.93 с

1.06 с

1.99 с

12 (все логические)

0.99 с

1.10 с

2.12 с

Формула автоподбора: все логические процессоры минус 2. Два обоснования.

Во-первых, SMT не даёт линейного прироста: логический сосед делит с ядром одни исполнительные блоки, а матричные умножения их и так насыщают. Отсюда провал на 12 потоках.

Во-вторых — и это важнее — два процессора намеренно оставлены под захват звука, клавиатурный хук и трей. Потоки лёгкие, но чувствительные к задержкам: если инференс займёт машину целиком, они будут ждать планировщика, а это лаги клавиатуры и пропуски в записи.

Формула проверена ровно на одной машине, так что значение всегда можно задать явно числом.

Плюс мелочи, которые в сумме дают многое

Приём

Выигрыш

Явный язык вместо автоопределения (language: "ru")

вдвое — экономит отдельный проход энкодера

Постоянно открытый аудиопоток

мгновенный старт записи

Модель в памяти + warm_up() на секунде тишины

первый реальный запрос не медленнее прочих

Отложенный импорт faster_whisper внутрь load()

иконка в трее появляется мгновенно

condition_on_previous_text=False

нет накопления контекста между кусками

Про warm_up() отдельная деталь: прогревочная секунда тишины гоняется с выключенным VAD. Иначе Silero честно отсечёт тишину, распознавать будет нечего и энкодер не прогреется — то есть первый реальный запрос всё равно окажется медленным.

Итоговые замеры

По 3 прогона, turbo, int8_float32:

Запись

Полное окно

Как сейчас

«привет»

3.8–4.1 с

1.1 с

фраза 5.5 с

4.7 с

1.3 с

фраза с терминами, 8 с

4.2 с

1.5 с

диктовка 15 с

4.5 с

~0 с — распозналась во время речи


Молчание — тоже результат, и о нём надо сообщать

Отдельный класс проблем, который никакими оптимизациями не лечится: пользователь честно наговорил фразу, а на входе была тишина. Микрофон выключен физическим переключателем на гарнитуре, замьючен в системе, отвалился USB, в настройках выбрано не то устройство.

Раньше это выглядело как «нажал, поговорил, ничего не появилось» — и дальше пользователь идёт смотреть логи или решает, что программа сломана. Худший из возможных сценариев: программа отработала штатно, а выглядит как баг.

Сейчас запись проверяется на наличие сигнала: если за всё время уровень так и не поднялся над порогом, программа не молчит, а прямо говорит, что звука не было и вас, скорее всего, не слышно — стоит проверить микрофон. Это дешёвая проверка поверх уже посчитанных данных, но по количеству сэкономленного недоумения она окупается лучше многих оптимизаций.

Логика тут та же, что и с UIPI-проверкой перед вставкой: любой сбой, который система отдаёт молча, приложение обязано перевести во внятное объяснение. Иначе пользователь остаётся один на один с «не работает» и без единой гипотезы, что делать дальше.

Смежный механизм работает давно: watchdog раз в 3 секунды проверяет, жив ли аудиопоток, и переоткрывает его, если пользователь выдернул микрофон или сменил устройство в системе. Сначала настроенное устройство, потом системное по умолчанию как fallback. Если не открылось ни одно — приходит уведомление «микрофон недоступен», а не тихий отказ.


WinAPI на голом ctypes, и почему не keyboard/pynput

pywin32 в зависимостях нет вообще. Весь системный слой — ctypes плюс stdlib (winreg, winsound). Итого пять внешних пакетов на всё приложение: faster-whisper, sounddevice, numpy, pystray, Pillow.

Библиотеки хоткеев не используются намеренно: они не дают надёжного push-to-talk (не различают физическое удержание и автоповтор так, как нужно) и регулярно попадают в эвристики антивирусов — для распространяемого .exe это критично.

Вместо них — низкоуровневый хук WH_KEYBOARD_LL. Из этого следует остальная архитектура.

Коллбэк хука обязан быть мгновенным. Windows отводит ему ограниченное время (LowLevelHooksTimeout), при превышении — молча снимает хук, и пользователь замечает это как лаги всей клавиатуры. Поэтому внутри _ll_proc только обновление множества нажатых клавиш и queue.put(...). Никакой работы. Единственный канал управления в приложении — queue.Queue[str] со строками "press", "release", "cancel", "limit", "hook_failed", которые разбирает отдельный worker.

Там же, в хуке, отсекаются собственные события по флагу LLKHF_INJECTED: наш же SendInput с Ctrl+V иначе вернулся бы обратно в хук.

Всего в работающем приложении девять потоков: pystray в главном, хук со своим message loop, оверлей со своим, worker, поток потоковой нарезки, опрос активного окна, watchdog аудиоустройства, коллбэк PortAudio и поток для winsound.Beep (он блокирует на всю длительность звука). Имя потока попадает в каждую строку лога — без этого логи неразбираемы.

Ссылку на коллбэк надо держать в атрибуте. Если объект соберёт GC, Windows вызовет освобождённую память.

sizeof(INPUT) на x64 обязан быть ровно 40 байт. Если не совпадёт, SendInput молча ничего не сделает — не бросит, не вернёт ошибку, просто не сработает. В коде стоит ассерт, чтобы поймать это на старте, а не в проде.

UIPI — отдельный сорт удовольствия. User Interface Privilege Isolation запрещает отправлять ввод окну процесса с более высоким integrity level. Проблема в том, что SendInput при этом возвращает успех, а текст просто не появляется. Поэтому уровень целостности активного окна проверяется заранее — через OpenProcessTokenGetTokenInformation(TokenIntegrityLevel) → последняя подавторитета SID — и пользователь получает внятное «текст в буфере, нажмите Ctrl+V», а не молчание.

Вставка фразы из истории — тоже неочевидный случай: в момент клика по пункту меню активным окном является меню трея, а не целевое поле. Поэтому фоновый поток раз в 0.2 с запоминает последнее чужое окно, фильтруя окна оболочки: пока пользователь ведёт мышь к значку в трее, активным успевает побыть Shell_TrayWnd — без фильтра история вставлялась бы в панель задач. А SetForegroundWindow асинхронен и вправе тихо отказать, так что результат проверяется опросом GetForegroundWindow, а не по коду возврата.

Общее правило слоя: у всех функций проставлены argtypes/restype — без них ctypes считает аргументы 32-битными и рвёт хендлы на x64. Ошибки логируются с GetLastError. Асинхронные вызовы проверяются опросом.

Здесь почти каждый второй вызов содержит ловушку, которая не даёт исключения. Отсюда плотность комментариев вида «без этого произойдёт X» — они отмечают места, где очевидное решение не работает.


Сборка: почему –onedir, а не –onefile

Самораспаковывающийся стаб --onefile ведёт себя как упаковщики вредоносов и заметно чаще ловит эвристику антивирусов. UPX отключён по той же причине. torch, tkinter, matplotlib, PyQt5 явно в excludes — иначе в сборку уезжают гигабайты ненужного.

Папку пользователь всё равно не видит: она уходит внутрь инсталлятора Inno Setup, который остаётся привычным одним .exe. Установка идёт в %LOCALAPPDATA%Programs с PrivilegesRequired=lowest — UAC не спрашивает.

Отдельная деталь, которую легко пропустить: в .gitattributes прописано *.bat text eol=crlf. cmd.exe на LF-переводах ломает разбор блоков if (...) и оператора || и падает с сообщениями вида 'ь' is not recognized. Файл однажды попал в репозиторий с LF и был сломан у всех, кто клонировал.

При каждом push в master GitHub Actions гоняет тесты, ruff, mypy и полную сборку — готовый инсталлятор можно забрать из артефактов, не дожидаясь релиза.


Честный список того, что здесь не так

Раздел, который обычно не пишут, но без него статья про опенсорс — реклама.

Monkeypatch внутренней функции библиотеки. adaptive_window подменяет faster_whisper.transcribe.pad_or_trim — не часть публичного API. Отсюда faster-whisper<2 в requirements. Есть fallback на полный проход при исключении, но он не ловит молчаливое изменение поведения: если в новой версии pad_or_trim начнёт игнорировать аргумент length, приложение просто станет медленным без единой ошибки в логе. Это цена ключевой оптимизации, и она принята осознанно.

Модель занимает 1.5–2 ГБ RSS постоянно, пользуетесь вы программой или нет. Выгрузка по таймауту вернула бы многосекундную задержку на следующем нажатии — то есть убила бы весь смысл. Размен, а не дефект, но на машине с 8 ГБ ощутимо.

Существенная часть кода не покрыта автотестами — весь WinAPI, аудио и оркестрация. Мокать WinAPI значит тестировать моки, поэтому покрыта только чистая логика: разбор конфига, парсинг хоткеев, постобработка текста, нарезка аудио, история фраз. Последствие принимаю всерьёз: регрессии в остальном ловятся руками.

Нетекстовое содержимое буфера теряется. Скопировали скриншот, продиктовали текст — скриншот не вернётся. В лог запись есть, уведомления нет. Чинится сохранением всех форматов через EnumClipboardFormats, но код заметно сложнее.

Список галлюцинаций жёстко русскоязычный — 16 паттернов, из них 12 русских («субтитры сделал», «продолжение следует»). Для другого языка список нужно писать самому, благо он в конфиге.

Диктовка на смешанных языках хрупкая. Язык определяется один раз на всю запись. Есть отдельная тонкость с vad.min_silence_duration_ms: при большом значении речь после короткой паузы попадает в один сегмент, и если там сменился язык, Whisper обрывает расшифровку — вторая половина записи теряется целиком. Дефолт 500 мс подобран именно из-за этого.

Только Windows x64. Кросс-платформенности нет и не планируется: код опирается на WinAPI напрямую.

GPU не используется. Целевая видеокарта — AMD, а работающие варианты ускорения добавили бы гигабайты к дистрибутиву ради выигрыша, которого на этой конфигурации может и не быть.


Как попробовать

Скачать установщикWhisperTypeSetup.exe, ~70 МБ, обычный мастер, без админ-прав. При первом запуске скачается модель (1.6 ГБ, один раз), дальше интернет не нужен.

SmartScreen почти наверняка покажет «Windows защитила ваш компьютер» — это стандартное поведение для программ без платной цифровой подписи, которая стоит десятки тысяч рублей в год. «Подробнее» → «Выполнить в любом случае». Пишу об этом сразу, потому что делать вид, что предупреждения нет, — нечестно.

Не хотите доверять чужому .exe — собирается одной командой:

git clone https://github.com/kirillrub108/whisper-type.git
cd whisper-type
build.bat

Нужен Python 3.11 с python.org (из Microsoft Store не подойдёт — из него не собирается exe). Скрипт сам создаст окружение, поставит зависимости и соберёт distWhisperTypeWhisperType.exe, а при установленном Inno Setup — и инсталлятор.

Всё под MIT. В репозитории лежит инженерная документация на 12 файлов — с картой потоков, разбором каждого модуля, списком исторических граблей и трёхдневным планом онбординга. Писалась она в том числе для того, чтобы правки от посторонних людей не ломали неочевидные инварианты вроде связки «узкое окно + without_timestamps».

Issues и PR открыты. Особенно интересны замеры на другом железе: формула автоподбора потоков проверена ровно на одной машине, и я не удивлюсь, если где-то она промахивается.

Автор: kirillrub108

Источник