Image

Голосовой ввод для Windows через Vosk своими руками

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

3e04f7ca33da1335a812ef21899359a2

Содержание

  • Введение

  • Определимся с целями

  • Выбор нейронки

  • Первые шаги

  • Первые проблемы

  • Вторая итерация

  • Проблемы второй итерации

  • Третья итерация

  • Заключение

Введение

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

28bbfc3e3437dab7791f79cd3b0adc5a

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

Продукта который позволял бы, как в андроиде, делать это одной кнопкой (+ через локальную ллм) — нет.

95dbf0084c072e36b3beaf7204896751

Лучшим выходом из моей ситуации было создать свое минималистичное решение, и вот как это было:

Определимся с целями

3af54cf6259712436bbeb1a9d2a6799eМы хотим использовать свой voice2text (real-time перевод аудио в текст) в самых разных приложениях, во всех, где можно вводить что угодно с клавиатуры. Поэтому ставим себе требование — распознанные слова должны сами печататься в активное текстовое поле любого вида.

092a859246dab787fc75bcfce8d3fd50Поток с микрофона мы будем отправлять в нейросеть, запущенную локально. Изначально был план использовать API от OpenAI, но self-host дает нам больше преимуществ, ведь так наш voice2text сможет работать без интернета, без проблем с конфиденциальностью и бесплатно.

edd4851453fbd7923c652bc58230a8e3Интерфейса у нас не будет, скрипт будет работать в headless режиме и запускаться автоматически при запуске ПК.

31c6f972af9d8560eaeea7af699b655dДля схожести с оригиналом включать/выключать микрофон будем по дефолту на самую верхнюю правую кнопку 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). Пока блок читается с микрофона, она успевает несколько раз его распознать, а значит может на ходу передумать и отредактировать распознанный ранее текст. Воспроизвести это проще всего повторяя похожие слова, в моем случае это «они» и «а не». Выглядеть это будет следующим образом:

cca22e25a756afbc40efbc2df30f75ea

Чтобы избежать этого эффекта при вводе текста, нужно учесть один момент:

если новая строка с распознаванием того же блока не является дополнением предыдущей, мы находим общий префикс, стираем текст до него и дописываем новую, более точную строку.

Теперь можно переходить к реализации этой логики.

Для полной симуляции ввода с клавиатуры будем использовать 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:UsersBathDuckAppDataRoamingMicrosoftWindowsStart MenuProgramsStartup

Весь код первой итерации

Определить, активно ли сейчас распознавание речи, можно по иконке микрофона в трее — она отображает текущее состояние.

7af84f032b2292ae2312cf55ee02c5da

Первые проблемы

Первые проблемы, с которыми мы сталкиваемся, используя свое решение — 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 ГБ ОЗУ. В повседневной работе это не большая проблема, но при запуске других требовательных к ОЗУ приложений, наш фоновый скрипт может им мешать.

e2c3c500470683129e5e7cd604348814

Вместо того, чтобы каждый раз убивать наше приложение в Диспетчере задач, изменим наш скрипт так, чтобы при нажатии на другую клавишу (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)

Весь код третьей итерации

Заключение

В итоге у нас получился полноценный, автономный скрипт для распознавания речи, работающий в фоновом режиме и не мешающий другим приложениям.

Также мы добавили возможность управлять количеством памяти, которое использует нейронка с помощью переключения между разными распознавателями.

e019e0324d61ec8fe0bba6557c005b00

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

Источник: habr.com

✅ Найденные теги: Голосовой, новости

ОСТАВЬТЕ СВОЙ КОММЕНТАРИЙ

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Каталог бесплатных опенсорс-решений, которые можно развернуть локально и забыть о подписках

галерея

Фото сгенерированных лиц: исследование показывает, что люди не могут отличить настоящие лица от сгенерированных
Нейросети построили капитализм за трое суток: 100 агентов Claude заперли…
Скетч: цифровой осьминог и виртуальный мир внутри компьютера с человечком.
Сцена с жестами пальцами, где один жест символизирует "VPN", а другой "KHP".
‼️Paramount купила Warner Bros. Discovery — сумма сделки составила безумные…
Скриншот репозитория GitHub "Claude Scientific Skills" AI для научных исследований.
Структура эффективного запроса Claude с элементами задачи, контекста и референса.
Эскиз и готовая веб-страница платформы для AI-дизайна в современном темном режиме.
ideipro logotyp
Image Not Found
Звёздное небо с галактиками и туманностями, космос, Вселенная, астрофотография.

Система оповещения обсерватории Рубина отправила 800 000 сигналов в первую ночь наблюдений.

Астрономы будут получать оповещения о небесных явлениях в течение нескольких минут после их обнаружения. Теренс О'Брайен, редактор раздела «Выходные». Публикации этого автора будут добавляться в вашу ежедневную рассылку по электронной почте и в ленту новостей на главной…

Мар 2, 2026
Женщина с длинными тёмными волосами в синем свете, нейтральный фон.

Расследование в отношении 61-фунтовой машины, которая «пожирает» пластик и выплевывает кирпичи.

Обзор компактного пресса для мягкого пластика Clear Drop — и что будет дальше. Шон Холлистер, старший редактор Публикации этого автора будут добавляться в вашу ежедневную рассылку по электронной почте и в ленту новостей на главной странице вашего…

Мар 2, 2026
Черный углеродное волокно с текстурой плетения, отражающий свет.

Материал будущего: как работает «бессмертный» композит

Учёные из Университета штата Северная Каролина представили композит нового поколения, способный самостоятельно восстанавливаться после серьёзных повреждений.  Речь идёт о модифицированном армированном волокном полимере (FRP), который не просто сохраняет прочность при малом весе, но и способен «залечивать» внутренние…

Мар 2, 2026
Круглый экран с изображением замка и горы, рядом электронная плата.

Круглый дисплей Waveshare для креативных проектов

Круглый 7-дюймовый сенсорный дисплей от Waveshare создан для разработчиков и дизайнеров, которым нужен нестандартный экран.  Это IPS-панель с разрешением 1 080×1 080 пикселей, поддержкой 10-точечного ёмкостного сенсора, оптической склейкой и защитным закалённым стеклом, выполненная в круглом форм-факторе.…

Мар 2, 2026

Впишите свой почтовый адрес и мы будем присылать вам на почту самые свежие новости в числе самых первых