PunchLawyer faults Akpabio over jail term view for suicide survivorsThe Jerusalem PostIran tensions, Syria talks put Iraqi Kurdistan at the center of Middle Eastern diplomacy - analysisBollywood HungamaEXCLUSIVE: Rohit Shetty-backed Krithi Shetty-starrer titled Hum Tere Pyaar Mein; to be directed by his protégé Vidhi GhodgaonkarESPN'We're a really hard team to beat': Brewers prove their way can take down anyoneRTP DesportoMundial de Hóquei. Portugal defronta Itália após desaire com EspanhaInquirerGroup backs MILF’s Ebrahim as new BARMM gov’t headZDF heuteEntdecken Sie das ZDF-NachrichtenstudioIl Fatto QuotidianoPedro Sanchez risponde a Luciana Littizzetto: “Io in Italia a guidare il campo largo? Devo rimanere il Spagna a cucinare paella per almeno quattro anni. Sempre tuo, Pedro Pedro Pedro”Daily MailRussell Brand trial latest updates: Comedian 'is a ruthless and manipulative sexual predator with a voracious sexual appetite', and told friend he 'wanted to bed 5,000 women in his lifetime', court hearsTagesschauVerurteilter Signa-Gründer Benko darf in den Hausarrest wechselnn-tvAngreifer verfasste Manifest: Polizei stuft Schulattacke in Schongau als rechte Gewalttat einالشرقاليابان تعتزم استدعاء سفير روسيا بعد اعتقال صحافي بتهمة التجسس
The Daily Newsstand · Free, Always
Wednesday, October 14, 2026

Своя диктовка для macOS на Deepgram: горячая клавиша, прогретое соединение и три грабли PyObjC

Translate

Я диктую по полторы-три минуты подряд: мысль для поста, письмо, правка к задаче. Готовые приложения для голосового ввода на маке я перепробовал, но для длинных кусков они не годились, и в итоге я за пару вечеров собрал свою диктовку на 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, вставка через буфер.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.