Привет! Меня зовут Иван Володин, я разработчик DD Planet, и я задался целью сделать для себя максимально удобный скрипт для набора текста речью.

Содержание
Введение
Одной из моих любимых фич Android в последнее время был голосовой ввод. Он показал себя на удивление хорошо, когда нужно было быстро записать большую задачу в ежедневник или быстро ответить текстом на сообщение в телеграмме (особенно если вы не фанат голосовых сообщений).

Я пытался найти в Windows похожий встроенный инструмент или готовое решение, но все они либо брали на себя слишком много неактуального для меня функционала, так как задумывались для людей с ограниченными возможностями, либо были платными, либо были недоступны для русского языка.
Продукта который позволял бы, как в андроиде, делать это одной кнопкой (+ через локальную ллм) — нет.

Лучшим выходом из моей ситуации было создать свое минималистичное решение, и вот как это было:
Определимся с целями
Мы хотим использовать свой voice2text (real-time перевод аудио в текст) в самых разных приложениях, во всех, где можно вводить что угодно с клавиатуры. Поэтому ставим себе требование — распознанные слова должны сами печататься в активное текстовое поле любого вида.
Поток с микрофона мы будем отправлять в нейросеть, запущенную локально. Изначально был план использовать API от OpenAI, но self-host дает нам больше преимуществ, ведь так наш voice2text сможет работать без интернета, без проблем с конфиденциальностью и бесплатно.
Интерфейса у нас не будет, скрипт будет работать в headless режиме и запускаться автоматически при запуске ПК.
Для схожести с оригиналом включать/выключать микрофон будем по дефолту на самую верхнюю правую кнопку TKL клавиатуры — Pause. Благо, она редко используется в других приложениях.
Выбор нейронки
Основных предложений для локального запуска на обычном домашнем железе лидера два: Vosk и Whisper. Vosk более легковесный, запускается на CPU и из коробки поддерживает стриминг потока из микрофона. Whisper поддерживает куда больше языков и имеет заметно большее разнообразие моделей.
Для наших целей лучше подойдет Vosk, так как нас нас интересует быстрая потоковая обработка речи, а не постанализ аудио на любом языке. Немаловажный аргумент — при распознавании русской речи лучше всего себя показал Vosk.
Первые шаги
ТЗ определен, переходим к коду. Пример работы Vosk от нейросети:
Пример работы Vosk от нейросети:
from vosk import Model, KaldiRecognizer import sounddevice as sd import json model = Model(r"C:\путь\к\vosk-model-small-ru-0.22") rec = KaldiRecognizer(model, 16000) def callback(indata, frames, time, status): if status: print(status) # Для RawInputStream преобразуем напрямую if rec.AcceptWaveform(bytes(indata)): result = json.loads(rec.Result()) print("Распознано:", result.get("text", "")) else: partial = json.loads(rec.PartialResult()) print("Промежуточно:", partial.get("partial", "")) with sd.RawInputStream(samplerate=16000, blocksize=8000, dtype='int16', channels=1, callback=callback): print("Начало записи, говорите...") while True: sd.sleep(1000)
Простейший демонстрационный пример работы Vosk показывает первый подводный камень: модель читает поток с микрофона блоками (по blocksize). Пока блок читается с микрофона, она успевает несколько раз его распознать, а значит может на ходу передумать и отредактировать распознанный ранее текст. Воспроизвести это проще всего повторяя похожие слова, в моем случае это «они» и «а не». Выглядеть это будет следующим образом:

Чтобы избежать этого эффекта при вводе текста, нужно учесть один момент:
если новая строка с распознаванием того же блока не является дополнением предыдущей, мы находим общий префикс, стираем текст до него и дописываем новую, более точную строку.
Теперь можно переходить к реализации этой логики.
Для полной симуляции ввода с клавиатуры будем использовать WinAPI. Такой подход позволит нам отправлять события нажатия клавиш напрямую в операционную систему, что освобождает нас от проблем поиска активных текстовых полей вручную и отправки данных в них.
user32 = ctypes.WinDLL('user32', use_last_error=True) INPUT_KEYBOARD = 1 KEYEVENTF_KEYUP = 0x0002 KEYEVENTF_UNICODE = 0x0004 VK_BACK = 0x08 class KEYBDINPUT(ctypes.Structure): _fields_ = ( ("wVk", ctypes.wintypes.WORD), ("wScan", ctypes.wintypes.WORD), ("dwFlags", ctypes.wintypes.DWORD), ("time", ctypes.wintypes.DWORD), ("dwExtraInfo", ctypes.POINTER(ctypes.wintypes.LONG)), ) class INPUT(ctypes.Structure): _fields_ = ( ("type", ctypes.wintypes.DWORD), ("ki", KEYBDINPUT), ("pad", ctypes.wintypes.BYTE * 8), ) def make_input_pair(wVk: int, wScan: int) -> list: """Создаёт массив из двух INPUT объектов: keydown + keyup.""" keydown = INPUT( type=INPUT_KEYBOARD, ki=KEYBDINPUT(wVk=wVk, wScan=wScan, dwFlags=KEYEVENTF_UNICODE, time=0, dwExtraInfo=None) ) keyup = INPUT( type=INPUT_KEYBOARD, ki=KEYBDINPUT(wVk=wVk, wScan=wScan, dwFlags=KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, time=0, dwExtraInfo=None) ) return [keydown, keyup] def send_text(text: str): """Печатает unicode-текст как будто пользователь набирает его.""" if not text: return arr = [inp for ch in text for inp in make_input_pair(0, ord(ch))] # Превращаем список в C-массив и отправляем user32.SendInput(len(arr), ctypes.byref((INPUT * len(arr))(*arr)), ctypes.sizeof(INPUT)) def send_backspaces(n: int): """Нажимает Backspace n раз (keydown+keyup).""" if n <= 0: return arr = [inp for inp in make_input_pair(VK_BACK, 0) * n] user32.SendInput(len(arr), ctypes.byref((INPUT * len(arr))(*arr)), ctypes.sizeof(INPUT))
Далее реализуем логику внесения правок, не забывая, что нейросеть может на ходу менять распознанный текст. Так как модель возвращает весь текст блока, считанного с микрофона (включая тот, что мы уже написали ранее), будем хранить кэш уже написанного в отдельной переменной.
printed_text = "" # что мы уже вывели «клавиатурой» def apply_text(new_text: str): """Сравнивает new_text с printed_text и вносит минимальные изменения на экране.""" global printed_text if new_text == printed_text: return # Находим длину общего префикса a, b = printed_text, new_text i = 0 max_i = min(len(a), len(b)) # быстрый посимвольный поиск LCP while i < max_i and a[i] == b[i]: i += 1 # Удаляем хвост старого текста to_delete = len(a) - i if to_delete > 0: send_backspaces(to_delete) # Допечатываем хвост нового текста to_type = b[i:] if to_type: send_text(to_type) printed_text = new_text
По аналогии с примером, загружаем Vosk и распознанный им текст передаём в apply_text
SMALL_MODEL_PATH = 'C:\\путь\\к\\vosk-model-small-ru-0.22' current_model = Model(SMALL_MODEL_PATH) recognizer = KaldiRecognizer(current_model, 16000) def process_text(txt: str, reset_printed=False): """Извлекает текст из результата и применяет его к экрану.""" global printed_text if txt: txt += " " apply_text(txt) if reset_printed: printed_text = "" def callback(indata, frames, time, status): if status: print(status) if not is_listening.is_set(): return if recognizer.AcceptWaveform(bytes(indata)): process_text(json.loads(recognizer.Result()).get("text", ""), reset_printed=True) else: process_text(json.loads(recognizer.PartialResult()).get("partial", "")) def audio_raw_input_stream(): try: with sd.RawInputStream(samplerate=16000, blocksize=8000, dtype='int16', channels=1, callback=callback): while is_listening.is_set(): sd.sleep(1) except: pass
Теперь, когда все готово, остается реализовать только главный цикл приложения. В нем мы будем следить за состоянием клавиши Pause:
При нажатии — менять флаг is_listening,
Если запись активна, останавливать поток audio_thread с функцией audio_raw_input_stream,
Если запись выключена — наоборот, запускать audio_thread.
Работа с audio_raw_input_stream в отдельном потоке необходима по двум причинам:
Чтобы поток с микрофона не читал данные непрерывно, когда запись не ведется,
И чтобы при отключении микрофона не «падал» главный цикл приложения.
В решении с отдельным потоком достаточно лишний раз нажать Pause, чтобы звук начал читаться из любого другого подключенного микрофона.
# Коды клавиш для лучшей читаемости VK_PAUSE = 0x13 is_listening = Event() audio_thread = None if __name__ == "__main__": try: last_pause_state = False while True: single_pause_state = user32.GetAsyncKeyState(VK_PAUSE) & 0x8000 # Обработка одиночного Pause (используем VK_PAUSE) if single_pause_state and not last_pause_state: is_listening.set() if not is_listening.is_set() else is_listening.clear() if is_listening.is_set(): audio_thread = Thread(target=audio_raw_input_stream) audio_thread.start() print(f"Режим распознавания: {'ВКЛ' if is_listening.is_set() else 'ВЫКЛ'}") # Сохраняем состояние для следующей итерации last_pause_state = single_pause_state time.sleep(0.05) finally: if audio_thread is not None: audio_thread.join()
Скрипт готов! Первую итерацию можно запускать и использовать. Чтобы запускать скрипт фоном, используем .pyw (или ярлык, в котором прямо укажем открытие через pythonw.exe). Чтобы скрипт запускался автоматически при старте ПК, переместим его в Автозагрузку: C:\Users\BathDuck\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup
Определить, активно ли сейчас распознавание речи, можно по иконке микрофона в трее — она отображает текущее состояние.

Первые проблемы
Первые проблемы, с которыми мы сталкиваемся, используя свое решение — vosk-small распознает русский текст недостаточно точно (20-25% ошибок, это слишком неудобно). Особенно, если речь не дикторская и микрофон не стоит близко к говорящему.
Пример
Распознано vosk-small: скажи который час девять нас без пяти только генеральский что генерал честно не в можешь тебе такие как давай снимать а ты закурить лена мёда я русскую ты мне котлы
Оригинал: дядь скажи который час девятнадцать без пяти у тебя котлы-то генеральские чтоль так я же генерал да ну че не веришь честное слово дядь не в масть тебе такие котлы давай снимай а ты мне закурить мена мена я тебе папироску ты мне котлы
Решим проблему самым простым способом — возьмем модель потяжелее: vosk-model-ru-0.42. Работает она в разы точнее, но запускается несколько минут.
Реализация, при которой после запуска ПК необходимо будет ждать несколько минут, пока тяжелая модель запустится и сможет работать — не очень user-friendly, поэтому решим эту проблему следующим образом: сначала запустим small модель и распознавать текст будем ей, в этот же момент в отдельном потоке поставим грузиться тяжелую. Как только она загрузится, поменяем их местами. Так мы сможем и распознавать текст сразу с момента запуска ПК, и повысить точность распознавания настолько быстро, насколько это возможно.
Вторая итерация
Изменим инициализацию моделей под новую логику
# Пути к моделям SMALL_MODEL_PATH = 'C:\\путь\\к\\vosk-model-small-ru-0.22' LARGE_MODEL_PATH = 'C:\\путь\\к\\vosk-model-ru-0.42' # Инициализация моделей small_model = Model(SMALL_MODEL_PATH) large_model = None # Текущая модель и распознаватель current_model = small_model recognizer = KaldiRecognizer(current_model, 16000) def load_large_model(): global large_model, current_model, recognizer, only_small_mode print("Загрузка более совершенной модели...") large_model = Model(LARGE_MODEL_PATH) current_model = large_model recognizer = KaldiRecognizer(current_model, 16000) print("Более совершенная модель загружена!")
И в основном потоке запустим отдельный поток, загружающий тяжёлую модель.
# Коды клавиш для лучшей читаемости VK_PAUSE = 0x13 is_listening = Event() audio_thread = None model_loader_thread = Thread(target=load_large_model, daemon=True) if __name__ == "__main__": try: model_loader_thread.start() ...
Проблемы второй версии
После долгого использования второй версии на практике все больше начинает смущать тот факт, что наш скрипт после инициализации тяжелой модели занимает 5-6 ГБ ОЗУ. В повседневной работе это не большая проблема, но при запуске других требовательных к ОЗУ приложений, наш фоновый скрипт может им мешать.

Вместо того, чтобы каждый раз убивать наше приложение в Диспетчере задач, изменим наш скрипт так, чтобы при нажатии на другую клавишу (Ctrl+Pause) тяжелая модель выгружалась из памяти, а распознавала текст снова small версия.
Заодно, так как наш скрипт не будет работать корректно, если мы запустим несколько его инстансов, мы запретим запуск более одного экземпляра нашей программы с помощью WinAPI мьютексов.
Третья итерация
Добавим логику удаления тяжёлой модели из памяти.
def load_large_model(): global large_model, current_model, recognizer, only_small_mode print("Загрузка более совершенной модели...") large_model = Model(LARGE_MODEL_PATH) if only_small_mode: free_large_model() return current_model = large_model recognizer = KaldiRecognizer(current_model, 16000) print("Более совершенная модель загружена и активирована!") def free_large_model(): global large_model if large_model is not None: try: large_model.free() except AttributeError: pass large_model = None gc.collect() only_small_mode = False def unload_large_model(): global large_model, current_model, recognizer, audio_thread print("Удаление из памяти более совершенной модели...") current_model = small_model recognizer = KaldiRecognizer(current_model, 16000) free_large_model()
И используем её в главном потоке.
# Коды клавиш для лучшей читаемости VK_PAUSE = 0x13 VK_CANCEL = 0x03 # Код для Ctrl+Pause # Состояние программы is_listening = Event() # Запускаем потоки audio_thread = None model_loader_thread = Thread(target=load_large_model, daemon=True) if __name__ == "__main__": try: # Проверка уникальности экземпляра mutex_name = "Global\\VoskSpeechRecognitionUniqueMutex" mutex = ctypes.windll.kernel32.CreateMutexW(None, False, mutex_name) last_error = ctypes.windll.kernel32.GetLastError() if last_error == 183: # ERROR_ALREADY_EXISTS print("Программа уже запущена! Завершение.") ctypes.windll.kernel32.CloseHandle(mutex) exit(0) print("Нажмите Pause/Break или Scroll Lock для включения/выключения режима распознавания...") print("Нажмите Ctrl+Pause для выхода из программы.") model_loader_thread.start() last_pause_state = False last_ctrl_pause_state = False while True: # Для Ctrl+Pause используем VK_CANCEL вместо VK_PAUSE ctrl_pause_state = user32.GetAsyncKeyState(VK_CANCEL) & 0x8000 # Для одиночной клавиши Pause используем VK_PAUSE single_pause_state = user32.GetAsyncKeyState(VK_PAUSE) & 0x8000 # Обработка Ctrl+Pause (используем VK_CANCEL) if ctrl_pause_state and not last_ctrl_pause_state: print("Обнаружено нажатие Ctrl+Pause") is_listening.clear() only_small_mode = not only_small_mode if only_small_mode: unload_large_model() else: model_loader_thread = Thread(target=load_large_model, daemon=True) model_loader_thread.start() # Обработка одиночного Pause (используем VK_PAUSE) if single_pause_state and not last_pause_state: is_listening.set() if not is_listening.is_set() else is_listening.clear() if is_listening.is_set(): audio_thread = Thread(target=audio_raw_input_stream) audio_thread.start() print(f"Режим распознавания: {'ВКЛ' if is_listening.is_set() else 'ВЫКЛ'}") last_pause_state = single_pause_state last_ctrl_pause_state = ctrl_pause_state time.sleep(0.05) finally: if audio_thread is not None: audio_thread.join() if mutex: ctypes.windll.kernel32.CloseHandle(mutex)
Заключение
В итоге у нас получился полноценный, автономный скрипт для распознавания речи, работающий в фоновом режиме и не мешающий другим приложениям.
Также мы добавили возможность управлять количеством памяти, которое использует нейронка с помощью переключения между разными распознавателями.

На будущее есть планы добавить пунктуацию (через vosk-recasepunc-ru-0.22), поддержку интеграции пользовательских слов в словарь Vosk и переключение на онлайн-режим распознавания.
