Своя диктовка для macOS на Deepgram: горячая клавиша, прогретое соединение и три грабли PyObjC
Я диктую по полторы-три минуты подряд: мысль для поста, письмо, правка к задаче. Готовые приложения для голосового ввода на маке я перепробовал, но для длинных кусков они не годились, и в итоге я за пару вечеров собрал свою диктовку на 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, вставка через буфер.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.