Я диктую по полторы-три минуты подряд: мысль для поста, письмо, правка к задаче. Готовые приложения для голосового ввода на маке я перепробовал, но для длинных кусков они не годились, и в итоге я за пару вечеров собрал свою диктовку на Python. Зажимаешь правый Command, говоришь, отпускаешь, и текст вставляется туда, где стоит курсор. Распознаёт Deepgram.
Статья про то, как это устроено, и про три места, где я потерял больше всего времени. Все три связаны с тем, что обычный Python-скрипт и приложение, которое macOS запускает сама при входе в систему, ведут себя по-разному. Код ниже взят из рабочего файла, лишнее я выкинул.
Зачем вообще своё
Раньше я пользовался Handy: это бесплатное приложение с локальными моделями, оно нажимает Cmd+V за вас. Для коротких фраз его хватало, а на длинных записях из текста начали пропадать куски.
В журнале Handy при долгой записи появлялась строка вида:
gigaam run: audio is 168.6 s, beyond the ~25 s window this model was trained on; transcription may be degraded. Split long audio into <=25 s segments
То есть модель GigaAM обучали на окнах примерно по 25 секунд, а я говорю по 2-3 минуты. Я прогнал свои реальные записи через GigaAM и через Deepgram nova-3 и сравнил объём распознанного текста:
Длина речи | GigaAM | Deepgram nova-3 | Потери |
|---|---|---|---|
168 сек | 1551 символ | 2066 символов | 25% |
92 сек | 887 символов | 988 символов | 10% |
24 сек | 284 символа | не сравнивал | в пределах окна |
Да и на коротком куске разница была заметна: на одной и той же фразе GigaAM выдал «нужно понятно структура», Deepgram написал «нужна понятная структура».
Локальный Whisper large-v3-turbo через MLX текст не терял, но на этой же записи в 168 секунд работал 244 секунды, то есть дольше самой речи. Deepgram с прогретым соединением укладывался в 2-3 секунды. Цены я смотрел в августе 2026: pre-recorded nova-3 стоит $0.0043 за минуту, словарь терминов (keyterm) добавляет ещё $0.0013 за минуту. Выходит несколько долларов в месяц при получасе диктовки в день, но цены у сервисов меняются, перед использованием проверьте прайс.
Схема
правый Command зажат → запись с микрофона в память правый Command отпущен → WAV → opus 16k через ffmpeg → POST в Deepgram → текст в буфер обмена → Cmd+V → буфер восстановлен
Стек:
Python 3.13,
sounddevice(PortAudio) для записи;pynputдля глобальной клавиши;PyObjC (
AppKit,Quartz) для буфера обмена, иконки в строке меню и опроса клавиатуры;requestsдля API;ffmpeg для сжатия звука;
Deepgram
nova-3, режим pre-recorded (файл целиком, а не поток).
Потоковое распознавание (WebSocket) я тоже мерил. Финальный текст приходит быстрее, но на моих записях качество ниже: вместо «по VPN» получилось «в ПВПН». Для диктовки до пары минут мне хватает отправки целого файла: так точнее, а по скорости разница не мешает.
Запись
Звук копится прямо в памяти, временных файлов нет:
def _capture(self, data, *_): self.chunks.append(data.copy()) self.now = float(np.abs(data).mean()) # уровень для анимации
Поток открывается с частотой 16 кГц, моно, int16. Когда клавишу отпускают, куски склеиваются, упаковываются в WAV внутри BytesIO и отправляются в ffmpeg через пайп:
p = subprocess.run( [FFMPEG, "-y", "-i", "pipe:0", "-c:a", "libopus", "-b:a", "16k", "-application", "voip", "-f", "ogg", "pipe:1"], input=buf.getvalue(), capture_output=True, ) return p.stdout
Сжатие нужно ради скорости загрузки: 74 секунды речи превращаются в 145 КБ вместо 2,3 МБ WAV. Я проверил битрейты opus 12k, 16k, 24k и 32k на одних записях. На время они почти не влияли, на качество распознавания тоже, поэтому остановился на 16k.
Короткие нажатия отсекаются: если речи меньше 0,7 секунды, запрос в Deepgram не уходит. Иначе каждый случайный тап по правому Command стоил бы денег и вставлял бы пустоту.
Запрос к Deepgram
HOST = "https://api.eu.deepgram.com" def reload(self): self.params = [("model", "nova-3"), ("language", "ru"), ("smart_format", "true")] self.params += [("keyterm", w) for w in keyterms()] def transcribe(self, opus: bytes) -> str: r = self.s.post(f"{HOST}/v1/listen", params=self.params, headers={"Content-Type": "audio/ogg"}, data=opus, timeout=120) j = r.json() if "results" not in j: raise RuntimeError(j.get("err_msg") or str(j)[:200]) return j["results"]["channels"][0]["alternatives"][0]["transcript"]
Две мелочи, на которые я наткнулся при выборе параметров.
Первая: если не передать model, Deepgram молча берёт старую base-general, и качество на русском заметно хуже. Вторая: язык лучше фиксировать как ru. Режим multi правильно распознавал английские слова, но на русских фразах ошибался сильнее («Так, Петер» вместо «Так, пятеро»).
Есть европейский адрес api.eu.deepgram.com (AWS, Франкфурт). Он отвечает с тем же ключом, что и основной, и на моих замерах был чуть быстрее: 0,33 секунды против 0,44 на запросе с прогретым соединением. Замер частный, из моей сети, у вас цифры могут быть другими.
Грабля первая: холодное TLS-соединение
Первая версия работала, но после паузы запись отправлялась мучительно долго. По замерам холодный запрос с 10 секундами речи занимал 3,4-4,4 секунды, а по уже открытому соединению 0,33-0,64, так что время в основном уходило на установку защищённого канала. Весь мой трафик идёт через VPN, пинг до основного сервера Deepgram в США был 200-560 мс, и TLS-рукопожатие через такой канал растягивалось на 0,4-1,1 секунды.
Значит, соединение надо держать открытым. requests.Session сам использует keep-alive, но если соединением долго не пользоваться, оно рвётся, поэтому я запустил фоновый поток, который раз в минуту дёргает дешёвый эндпоинт:
class Session: def __init__(self): self.s = requests.Session() self.s.headers.update({"Authorization": f"Token {KEY}"}) self.reload() threading.Thread(target=self._keep_warm, daemon=True).start() def _keep_warm(self): while True: try: self.s.get(f"{HOST}/v1/projects", timeout=10) except Exception: pass time.sleep(60)
Ошибки пинга я глушу намеренно: если сети нет, диктовка всё равно покажет ошибку при настоящем запросе. Пинговать нужно через тот же self.s, что и расшифровку, иначе греется не то соединение. После этого на 74 секундах речи я стабильно получал 0,75-1,06 секунды вместо 3-5.
Грабля вторая: CGEventSourceKeyState врёт про модификаторы
Для push-to-talk я слушаю клавиши через pynput: on_press начинает запись, on_release заканчивает. Обработчики сразу отдают работу в отдельный поток, потому что если задержаться в колбэке, macOS решит, что программа зависла, и отключит слежение.
Но событие «клавишу отпустили» доходит не всегда. Если оно потерялось, запись идёт вечно и микрофон остаётся открытым. Поэтому я добавил сторожа: поток, который каждые 0,3 секунды спрашивает у системы, зажата ли клавиша, и если нет, сам завершает запись.
Сначала я спрашивал систему про саму клавишу, примерно таким вызовом:
from Quartz import CGEventSourceKeyState, kCGEventSourceStateHIDSystemState held = CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, KEYCODE_RIGHT_CMD)
С ним каждая запись обрывалась ровно через 0,3 секунды после начала, и в логе стояло «клавиша отпущена — дожимаю сам», хотя палец я не убирал. Оказалось, что CGEventSourceKeyState на клавишах-модификаторах (Command, Option, Control, Shift) возвращает False, даже если клавиша зажата. Почему, я не разбирался, но воспроизводится это стабильно.
Заработало, когда я стал опрашивать флаги модификаторов:
from Quartz import CGEventSourceFlagsState, kCGEventSourceStateHIDSystemState HOTKEYS = { # клавиша pynput и бит этой же клавиши во флагах "правый Command": (keyboard.Key.cmd_r, 0x10), "правый Option": (keyboard.Key.alt_r, 0x40), "правый Control": (keyboard.Key.ctrl_r, 0x2000), } held = CGEventSourceFlagsState(kCGEventSourceStateHIDSystemState) & hotkey()[1]
Бит 0x10 для правого Command отличает его от левого: общий флаг Command ставится для обеих клавиш, а по отдельным битам можно понять, какая именно зажата. Биты 0x40 и 0x2000 для правых Option и Control я тоже завёл в таблицу, но сам пользуюсь правым Command, так что в работе проверен только 0x10.
Весь сторож целиком:
def _watchdog(self): while True: time.sleep(0.3) if not self.listener.running: print("система отключила слежение за клавишами — включаю заново") self._listen() if not self.recording: continue held = CGEventSourceFlagsState(kCGEventSourceStateHIDSystemState) & hotkey()[1] if not held: threading.Thread(target=self.finish, daemon=True).start() elif self.rec.seconds > MAX_SECONDS: threading.Thread(target=self.finish, daemon=True).start()
Заодно он проверяет, жив ли слушатель: macOS может отключить глобальное слежение за клавиатурой, если сочтёт приложение подозрительным, и тогда listener.running становится False. А ещё стоит потолок записи в 8 минут, чтобы забытая клавиша не копила часовой файл.
Грабля третья: pbcopy превращает русский в кракозябры
Вставка текста устроена так: положить текст в буфер обмена, нажать Cmd+V, вернуть старое содержимое буфера. Cmd+V нажимает pynput. Через System Events тоже можно, но ему нужно отдельное разрешение «Автоматизация», без которого вставка молча не срабатывает, а «Универсальный доступ» у приложения и так уже есть.
Первая версия клала текст в буфер так:
subprocess.run(["pbcopy"], input=text.encode("utf-8"))
Из терминала всё работало. Когда я оформил диктовку в приложение и поставил автозапуск при входе в систему, русский текст стал вставляться иероглифами: байты UTF-8 читались как latin1. Виновато оказалось окружение: pbcopy определяет кодировку по локали, то есть по переменным вроде LANG, а у процесса, который запускает launchd (системный запускатель), этих переменных нет, и pbcopy берёт кодировку по умолчанию.
В итоге я выкинул внешнюю программу и кладу строку в буфер через AppKit: строка уходит как строка, и угадывать кодировку некому:
from AppKit import NSPasteboard, NSPasteboardTypeString def paste(text: str): pb = NSPasteboard.generalPasteboard() old = pb.stringForType_(NSPasteboardTypeString) pb.clearContents() pb.setString_forType_(text, NSPasteboardTypeString) time.sleep(0.05) kb = keyboard.Controller() with kb.pressed(keyboard.Key.cmd): kb.press("v") kb.release("v") time.sleep(0.4) pb.clearContents() if old: pb.setString_forType_(old, NSPasteboardTypeString)
Паузы я подбирал на глаз: 0,05 секунды между записью в буфер и нажатием Cmd+V нужны, чтобы буфер успел обновиться. 0,4 секунды перед восстановлением нужны, чтобы целевое приложение успело прочитать вставленный текст: если вернуть старый буфер сразу, часть программ вставит прежнее содержимое. Какое минимальное безопасное значение, я не выяснял, а на медленных Electron-приложениях запас может понадобиться больше.
Та же история с окружением вылезла ещё раз: при автозапуске у процесса короткий PATH без /opt/homebrew/bin, ffmpeg не находится, и диктовка молча перестаёт работать. Я ищу его по расширенному списку путей и пишу понятную ошибку в лог, если не нашёл:
FFMPEG = shutil.which("ffmpeg", path=os.environ.get("PATH", "") + ":/opt/homebrew/bin:/usr/local/bin")
А в LaunchAgent дополнительно прописан полный PATH через EnvironmentVariables.
Одна копия за раз: flock
При автозапуске легко получить вторую копию: одна поднялась сама, вторую я открыл руками. Тогда обе слушают одну клавишу, и каждая вставляет свой текст, в итоге диктуешь одну фразу, а в поле появляется два экземпляра.
Я поставил файл-замок и flock в самом начале скрипта, до загрузки всего остального:
_lock = open(BASE / ".lock", "w") try: fcntl.flock(_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: print("Диктовка уже запущена — вторая копия выходит.") raise SystemExit(0)
Переменную _lock нужно держать живой всё время работы программы: если сборщик мусора закроет файл, замок снимется. Замок отпускается сам, когда процесс умирает, поэтому после падения ничего чистить не надо.
Выход с кодом 0 здесь тоже сделан нарочно. В LaunchAgent стоит KeepAlive с условием SuccessfulExit: false: launchd перезапускает программу только если она упала. Вторая копия выходит штатно, и её никто не воскрешает. Если бы я выходил с ненулевым кодом, launchd пытался бы поднимать её по кругу. Для страховки там же стоит ThrottleInterval в 60 секунд.
Словарь: keyterm
Deepgram умеет принимать список слов, которые нужно распознавать точнее: параметр keyterm, его можно повторять в запросе сколько нужно. У меня это обычный текстовый файл, по слову или фразе на строку, строки с # пропускаются:
def keyterms(): f = BASE / "slovar.txt" if not f.exists(): return [] words = [w.strip() for w in f.read_text(encoding="utf-8").splitlines()] return [w for w in words if w and not w.startswith("#")]
Файл правится прямо из окна настроек в меню, а после сохранения Session.reload() пересобирает параметры запроса, перезапускать ничего не надо.
Пригождается он в основном на английских названиях в русской речи: без словаря «Threads» возвращался как «третс», а со словарём сервис пишет правильно. Документация Deepgram ограничивает словарь примерно 500 токенами на запрос, это порядка двух-пяти десятков терминов, и каждый запрос с keyterm стоит чуть дороже. Поэтому кладу туда только то, что сервис действительно калечит. Параметр keywords в nova-3 отключён, на него API отвечает ошибкой с советом использовать keyterm.
Что осталось неидеальным
Ключ лежит в
.envрядом со скриптом. Для личной машины этого хватает, но если выкладывать приложение другим людям, нужно хранить его в связке ключей.Для работы нужны три разрешения: микрофон, «Универсальный доступ» (чтобы нажимать Cmd+V) и «Мониторинг ввода» (чтобы ловить клавишу). Без последнего
pynputничего не говорит, просто не получает событий. Первый раз это выглядит как «ничего не происходит».Приложение собрано PyInstaller, но код читается из внешней папки: правишь файл, перезапускаешь агента командой
launchctl kickstart -k, и пересборка не нужна.Буфер обмена я восстанавливаю только для текста. Если у вас в буфере лежала картинка, после диктовки она пропадёт.
Для каждой записи сохраняется последний звуковой файл, на случай если текст потерялся и хочется переслушать. Он перезаписывается каждый раз, так что история не копится.
Итог в цифрах
Длинная запись (168 сек): локальная GigaAM теряла 25% текста, Deepgram возвращал полный текст за 2-3 секунды.
Прогретое соединение: 0,33-0,64 сек на 10 секундах речи вместо 3,4-4,4 при холодном.
Три грабли:
CGEventSourceKeyStateне видит модификаторы,pbcopyбез локали портит кириллицу, и копия приложения при автозапуске легко удваивается.
Весь код укладывается примерно в шестьсот строк, значительная часть из них приходится на окно настроек и анимацию волны. Если вам нужна только сама диктовка без окон, ядро помещается в сотню строк: запись, ffmpeg, один POST, вставка через буфер.