EN: Local push-to-talk dictation for macOS (Apple Silicon), fully offline.
Hold a hotkey (right Option by default; any modifier, Fn/🌐, F-key or combo
like ⌃Space — configurable from the menu), speak, release — text is typed into
whatever app has focus; the clipboard is restored afterwards.
Pipeline: Silero VAD → Whisper large-v3-turbo on MLX (with a user dictionary
fed via initial_prompt — the main quality lever for jargon) → optional
filler-word cleanup by a local Qwen3-4B (runs only when needed) → paste via
synthetic ⌘V → SQLite history. An optional review window offers the raw
transcript and two style rewrites to pick from before pasting. Ships as a menu bar app with a launchd daemon
(auto-start, self-healing microphone selection, permission bootstrap), a
caret-anchored recording capsule with level meter, sound cues, a status window
(service / TCC permissions / microphone / models / hotkey), and a model manager
(Whisper large-v3, medium, 4-bit; Qwen3 1.7B–30B, GigaChat) — pick per machine.
Docs below are in Russian; the code and daemon.sh are self-explanatory.
Install: uv sync && ./daemon.sh install. License: MIT.
Локальная push-to-talk диктовка для macOS (Apple Silicon). Зажал горячую клавишу (по умолчанию правый Option) — говоришь — отпустил — текст вставился в активное поле любого приложения, а буфер обмена остался каким был. Полностью офлайн: аудио и текст не покидают машину.
горячая клавиша ──► запись с микрофона (sounddevice, 16 кГц)
│
▼
VAD Silero (onnx): есть ли речь? обрезка тишины по краям
│
▼
Whisper large-v3-turbo (MLX, GPU) — словарь терминов из
terms.txt подсказывается через initial_prompt
│
▼
есть слова-паразиты? ──да──► Qwen3-4B-Instruct (MLX, 4-bit):
│нет убрать «эээ/ну/короче», оговорки
▼ │
вставка: pbcopy + синтетический ⌘V (Quartz CGEvent)
│
▼
history.sqlite3: чистый текст, сырой текст, длительность,
приложение-получатель
Вся ML-работа (Whisper, Qwen, VAD) живёт в одном выделенном потоке с очередью — MLX не переносит вызовы из разных потоков. Клавиатуру слушает pynput, меню-бар — rumps (NSStatusItem), приложение-получатель определяется через NSWorkspace.
uv sync
cp terms.example.txt terms.txt # свой словарь терминов
./daemon.sh install # демон + автозапуск при входе; разово: uv run dictate.pyПри первом запуске приложение спросит диалогом, какие модели скачать с HuggingFace (предложит дефолтные: Whisper large-v3-turbo + Qwen3-4B, ~4 ГБ; отмена — тоже дефолтные). Пока они качаются, в меню-баре висят проценты (⬇️25%), а подробности — в окне состояния; диктовка заработает после закачки. Дальше модели можно скачивать/менять/удалять через меню-бар → «Модели». Недостающие разрешения (Микрофон, Универсальный доступ, Мониторинг ввода) выдаются через меню-бар → «Настроить разрешения…»: мастер проводит по недостающим по одному — объясняет, зачем нужно, показывает системный запрос или открывает нужный раздел Настроек с файлом в Finder и ждёт результата. Пачкой при каждом старте, как раньше, ничего не всплывает.
- Push-to-talk: зажми горячую клавишу (по умолчанию правый Option), говори, отпусти — текст вставится.
- Toggle-режим (длинная диктовка): короткий тап (<0.35 с) — запись идёт без удержания; второй тап — стоп и вставка.
- Esc во время записи — отмена без вставки.
- Горячая клавиша меняется в меню «Горячая клавиша»: готовые варианты
(правый/левый Option, правый Command/Control/Shift, Fn/🌐, ⌃Space, ⌥Space) или
«Своё сочетание…» — открывается окно, нажимаешь что хочешь: одиночный
модификатор, F1–F20 или клавишу с модификаторами (⌘⇧D). Буквы запоминаются
по физической кнопке, раскладка не важна. Голая буква/цифра/пробел не
принимаются — сработали бы при обычном наборе. В
config.jsonэто строкаhotkey(alt_r,fn,ctrl+space,cmd+shift+d,f13; формат описан вhotkey.py). Для Fn/🌐 в Настройках → Клавиатура поставь «При нажатии клавиши 🌐» = «Ничего не делать», иначе macOS параллельно откроет эмодзи. Если хоткей — модификатор и вместе с ним нажата другая клавиша (⌘C на правом Command), это сочетание, а не диктовка: запись тихо отменяется. - Голосовые команды: короткая фраза вместо диктовки — «удали» стирает
текущую строку (в терминале ⌃U), «отмени» убирает последнюю вставку (ровно
столько ⌫, сколько было вставлено), «удали слово / удали всё / выдели всё /
повтори», «отправь» (Return), «новая строка» (⇧Return), «абзац», «таб /
энтер / эскейп», «скопируй / вырежи / вставь», «переведи», «строгий /
разговорный / обычный стиль / как сказано» (профиль текущего приложения).
Форма — либо вся фраза целиком, либо хвост: «созвон переносим на завтра,
отправь» — текст вставится, потом Return (хвостом работают отправка,
переносы, таб/энтер, переведи, сниппеты; «удали» в хвосте — просто слово).
Внутри текста команды не ищутся. Сниппеты — фраза → готовый текст («моя
почта», «подпись»), в
commands.json(создаётся из шаблона по пункту меню «Файл команд и сниппетов…»; там же свои формулировки команд и список выключенных; файл перечитывается на лету). Меню «Команды и сниппеты» → «Список команд…» показывает, что действует сейчас; тумблерcommands. - Буфер обмена: вставка идёт через ⌘V, но то, что лежало в буфере до
диктовки (текст, картинка, файлы — любые типы), возвращается на место через
~0.6 с после вставки. Если за это время ты сам что-то скопировал, твоя копия
остаётся. Продиктованный текст помечен как transient — менеджеры буфера
(Maccy, Paste) его в историю не пишут. Тумблер — «Возвращать буфер обмена
после вставки» в меню (
restore_clipboardв конфиге).
Иконка показывает состояние: ⬇️25% модель качается с HuggingFace (первый
запуск и смена модели; проценты — по кэшу на диске) → ⏳ модели греются,
то есть грузятся с диска в память (после старта ~20–30 с) → 🎙️ готов →
🟠 идёт запись;
Скачивание и прогрев разведены намеренно: первое — это гигабайты по сети (на медленном канале — часы), второе — секунды с диска. Пока идёт закачка, в окне состояния видно, сколько скачано, с какой скоростью и сколько осталось; та же строка раз в полминуты уходит в лог. Если данные перестали идти, там будет «данные не идут уже N мин» — это про сеть, перезапуск не поможет (закачка сама продолжится с места обрыва).
Во время записи у текстового курсора появляется капсула-индикатор со столбиками уровня (после отпускания клавиши — пульсирующие точки «распознаю»), а короткие системные звуки подтверждают старт (Tink), вставку (Pop) и отброшенную запись (Basso). И то и другое настраивается в меню.
Меню по клику на иконку:
-
Состояние и разрешения… — окно-панель: служба (готов / грузится / нет микрофона, PID, версия, кнопки «Перезапустить» и «Лог»), три TCC-разрешения с кнопками «Запросить»/«Открыть Настройки»/«Перезапустить» и путём к процессу, микрофон и список входов, модели (скачана / скачивается X из Y / не скачана / скачана частично — «Скачать» и «Докачать»), горячая клавиша с кнопкой «Сменить…». Открывается само при первом запуске и когда чего-то не хватает (в меню тогда горит
⚠️ ); -
Настроить разрешения… — мастер: недостающие разрешения по одному. Отдельно ловит случай «галка включена, а не работает»: «Мониторинг ввода» и «Универсальный доступ» macOS отдаёт только новому процессу, поэтому состояние проверяется дочерним процессом и пункт меню превращается в «
⚠️ Разрешения выданы — перезапустить службу»; -
Микрофон: … — с какого устройства сейчас пишем (обновляется живьём);
-
Профиль «<приложение>» — стиль вставки для приложения, в котором сейчас курсор: Чистка / Разговорный (без точек) / Строгий (письменный) / Неформальный (по-человечески) / Кратко (сжать до сути) / Как сказано (без LLM) / Перевод → EN. Выбор запоминается в
config.json; -
Стиль по умолчанию — то же для всех приложений без своего профиля;
-
Окно постобработки — выключено по умолчанию. Когда включено, фраза не вставляется сразу: у курсора всплывает окно с вариантами — «Как сказано» (сырой Whisper, без LLM), обычный результат и до четырёх стилей на выбор. Стили лежат в ячейках (по умолчанию «Строгий», «Неформальный», «Кратко», четвёртая выключена); любую ячейку можно выключить пунктом «— выключена» — тогда стиль не считается и строки в окне не занимает, окно становится короче. Клик по варианту вставляет его в то же поле. Зачем: LLM-чистка иногда меняет смысл — ослышка распознавателя плюс «исправление» модели, — и окно даёт откатиться на сырой текст или взять другую формулировку, не передиктовывая. Сырой и обычный варианты готовы сразу, стили дописываются на ходу («считаю…»), выбирать можно не дожидаясь. Окно не забирает фокус (нативная неактивирующая панель) — поэтому ⌘V попадает ровно туда, куда попал бы и без окна, но и выбор только мышью: клавиши ушли бы в чужое поле ввода. Стилевые переделки идут с подстраховкой: диктовка часто выглядит как задание («Объясни, как работают хуки»), и модель норовит на него ответить — задание для неё поэтому идёт после текста, а результат отбрасывается, если он длиннее исходника или если вопрос превратился в ответ (в строке останется сырой текст). «Не вставлять» или крестик — отказ; новая диктовка закрывает протухшее окно сама. С голосовой командой («…отправь») окно не показывается: команда выполняется сразу за вставкой;
-
Перевод → EN (везде) — глобальный тумблер: диктуешь по-русски, вставляется английский перевод (локальный Qwen, ~+1.5 с);
-
Язык распознавания — Русский (по умолчанию) / English / Автоопределение. Фиксированный язык избавляет Whisper от прохода детекции (~1 с на M4 Air, ~0.25 с на Studio); автоопределение — если диктуешь на разных языках вперемешку;
-
Мой голос — подменю голосового отпечатка (ECAPA-эмбеддинги): речь, не похожая на записанный отпечаток (ТВ, коллеги), не транскрибируется. Первая строка всегда показывает состояние: когда записан, на каком микрофоне, сколько было речи, текущий порог. Внутри:
- «Записать отпечаток заново…» — открывает окно записи (см. ниже);
- «Проверить: узнаю ли я тебя…» — то же окно на 4 секунды: показывает сходство с отпечатком и запас до порога. Способ убедиться, что фильтр работает, не рискуя потерять диктовку;
- «Строгость» — строго 0.55 / обычно 0.40 / мягко 0.28.
Отпечаток привязан к микрофону: записанный на встроенный не подойдёт для AirPods (другой тракт — сходство падает), поэтому при несовпадении в меню висит предупреждение. Запись идёт через тот же аудиопоток, что и диктовка, и обрезается по VAD ровно как диктовка — иначе эмбеддинги несопоставимы. Сходство печатается в лог и при удачной вставке (
голос 0.83), так что запас до порога видно на реальных фразах.Окно записи (
enrollwindow.py) вместо диалога с обратным отсчётом: запись стартует не по таймеру, а по кнопке — человек успевает прочитать инструкцию и приготовиться. В окне сразу три вещи, которых в капсуле не покажешь: текст для чтения (иначе фразу приходится придумывать на ходу, запинаясь и молча), полоса уровня звука (микрофон вообще слышит?) и счётчик НАБРАННОЙ ЧИСТОЙ РЕЧИ — паузы в зачёт не идут, и без счётчика непонятно, почему двенадцать секунд превратились в три. Набрал раньше срока — «Остановить», передумал — «Отмена». Отчёт показывается в том же окне, там же кнопка «Записать заново», если результат не понравился. Состояния: готов → запись → считаю → отчёт; -
Последние (клик — скопировать) — пять последних распознанных фраз, клик по любой кладёт её в буфер обмена;
-
Горячая клавиша: … — готовые варианты и «Своё сочетание…» (см. «Управление»), «Как это работает…» — краткая справка;
-
LLM-чистка паразитов — галка вкл/выкл второй ступени (Qwen); выключение действует сразу, без перезапуска;
-
Команды и сниппеты — тумблер голосовых команд, «Список команд…», «Файл команд и сниппетов…» (
commands.json, см. «Управление»); -
Возвращать буфер обмена после вставки — галка (см. «Управление»);
-
Индикатор и звуки — капсула: показывать/нет, размер (супермаленький / маленький / обычный), иконка (столбики уровня, точка, кольцо, микрофон, волна), цвет (8 акцентов), фон (как в системе/тёмный/светлый), положение (у курсора — через Accessibility, иначе внизу экрана), «Показать пример»; звуки: общая галка и отдельный системный звук (или «без звука») для старта записи, вставки и отказа. Если вывод по умолчанию — Bluetooth-наушники, звуки идут во встроенный динамик Mac: звук в AirPods перещёлкивает их профиль, и микрофон на 1–2 с отдаёт тишину;
-
Модели — разделы по ролям (распознавание: Whisper large-v3-turbo / large-v3 / medium / turbo-4bit / small; чистка текста: Qwen3-4B / 1.7B / 14B / 30B-A3B, GigaChat 3.1 Lightning): что скачано, что качается (X из Y), сколько весит; скачать / удалить / сделать активной (перезапуск), открыть папку кэша;
-
Словарь терминов — общий словарь
terms.txtи его слои. Слой — файлterms.d/<имя>.txt, который включается для конкретного приложения (или как слой по умолчанию) и уходит в подсказку Whisper ПЕРЕД общим словарём. Нужно это из-за бюджета: вinitial_promptвлезает ~210 токенов, и один словарь на все случаи занимает его целиком. Со слоемdevв терминале подсказываются git и модели, со слоемchatв переписке — названия систем и городов; правки подхватываются на лету, перезапуск нужен только дляterms.txt; -
Лог… — открывает
dictate.log: каждая диктовка с таймингами (asr 0.9s + llm 1.5s → Telegram), сырой вариант до чистки, диагностика микрофона; -
О программе · X.Y.Z — три группы, в каждой сначала строка состояния (серая, не кликается), потом действия над ним: что установлено («Dictate 0.15.1 · хеш · дата» и «Скопировать данные для отчёта об ошибке»); обновления (что известно, кнопка «Проверить обновления» / «⬆️ Установить …», галка «Проверять при запуске», справка «Как обновляется Dictate…»); обслуживание («Перезапустить службу», с пометкой «⬆️ на диске новее», если код обновили руками);
-
Выход — остановить приложение (если стоит демон с KeepAlive, launchd поднимет его снова — для полной остановки
./daemon.sh uninstall).
Семантическая: MAJOR.MINOR.PATCH (MAJOR — несовместимые изменения формата
конфига/истории, MINOR — новые возможности, PATCH — исправления). Номер
хранится в трёх местах, их держат синхронно: VERSION в dictate.py,
version в pyproject.toml и git-тег vX.Y.Z на релизном коммите.
Что за версия работает прямо сейчас, видно четырьмя способами:
uv run dictate.py --version— например0.4.0+3 · a1b2c3d · 17.08.2026;- первая строка меню «О программе · X.Y.Z»; строка под ней копирует в буфер справку для отчёта об ошибке (версия, macOS, чип, модели, микрофон);
- первая строка
dictate.logпри каждом старте; - строка «Версия» в окне «Состояние и разрешения».
Расшифровка: +3 — коммитов после тега, затем хеш и дата коммита; правки
в конце — есть незакоммиченные изменения. Версия снимается один раз при
старте: после git pull в памяти работает старый код, и показывать надо
именно его — а панель состояния добавит «⬆️ на диске новее — перезапусти».
По умолчанию — только по кнопке, фоновых походов в сеть у диктовки нет. Нажми
«Проверить обновления» в меню «О программе · X.Y.Z» или ту же кнопку в
окне состояния: спрашиваем GitHub через git ls-remote по HTTPS — репозиторий
публичный, поэтому ни ключей, ни токенов, ни агента не нужно; обновление
никак не зависит от настроек разработчика (SSH для push — отдельно, только на
remote.origin.pushurl). Лимит GitHub API не тратится, ни один объект не
скачивается: сравниваются только имена ссылок. Отставание от main
определяется без git fetch — проверкой, есть ли объект удалённой верхушки в
локальной базе.
Два сигнала: вышел релизный тег новее VERSION, либо на main есть коммиты,
которых у нас нет. Если новое нашлось — делается git fetch (только в .git,
рабочая копия не тронута), чтобы показать список изменений; в меню-баре к 🎙️
добавляется ⬆️, результат остаётся в строке «Обновления: …» до следующей
проверки, а кнопка под ней превращается в «⬆️ Установить …»: клик показывает,
что изменилось, и спрашивает подтверждение.
Галка «Проверять при запуске» (выключена по умолчанию) добавляет ровно один такой запрос через минуту после старта — только пометка ⬆️, ничего не ставится.
Обновление никогда не ставится само — приложение слушает клавиатуру и
микрофон, подменять такой код без ведома хозяина нельзя. По нажатию: проверка
на незакоммиченные правки (при их наличии — отказ), git pull --ff-only,
компиляция всех .py, при изменении pyproject.toml/uv.lock — uv sync
(ищется в PATH, ~/.local/bin, ~/.cargo/bin, /opt/homebrew/bin — у демона
launchd PATH куцый), затем перезапуск службы. Если код не собрался или uv sync
упал — git reset --hard на прежний коммит, чтобы демон не ушёл в цикл
перезапусков под KeepAlive.
Если код обновили снаружи (git pull руками), в меню «О программе» появляется
«⬆️ Перезапустить службу — на диске новее»; та же кнопка без пометки — обычный
перезапуск.
Выпуск релиза:
# поднять номер в dictate.py и pyproject.toml, закоммитить, затем:
git tag -a v0.4.0 -m "Краткое описание релиза"
git push --tagsdaemon.sh управляет LaunchAgent com.kkd.dictate
(~/Library/LaunchAgents/com.kkd.dictate.plist):
./daemon.sh install— генерирует plist с абсолютными путями к.venv/bin/python3иdictate.py, загружает агент (launchctl bootstrap) и включает автозапуск при входе в систему (RunAtLoad);./daemon.sh restart— перезапуск (launchctl kickstart -k); нужен после правок кода и после выдачи разрешений — TCC применяется к новому процессу;./daemon.sh uninstall— остановить и убрать из автозапуска.
Свойства: KeepAlive — упавший процесс launchd поднимает автоматически;
stdout/stderr пишутся в ~/dictate/dictate.log; рабочий каталог — корень
проекта, поэтому terms.txt, history.sqlite3 и лог лежат рядом со скриптом.
Разрешения TCC привязаны к бинарнику .venv/bin/python3 (это «личность»
демона для macOS). После пересоздания venv (uv sync с нуля) macOS может
спросить разрешения заново — приложение само покажет диалоги при старте.
Диктовку можно запустить и без демона — uv run dictate.py из терминала
(тогда TCC-разрешения нужны терминалу); не запускай обе копии одновременно,
обе будут слушать хоткей и вставлять текст дважды.
- Словарь
terms.txtподсказывается Whisper прямо при распознавании — главный инструмент качества, дописывай туда всё, что диктовка перевирает. - LLM-чистка запускается только если в тексте есть слова-паразиты (эээ, ну...) — чистые фразы вставляются без задержки на LLM.
- Микрофон самолечится: если активное устройство отдаёт тишину (AirPods в кейсе, крышка закрыта, устройство отвалилось) — при следующем нажатии клавиши поток переоткрывается и выбирается живой микрофон по пробе сигнала.
- Защита от галлюцинаций Whisper: VAD не пускает в распознавание записи без
речи, «эхо словаря» (текст, целиком состоящий из слов подсказки)
отбрасывается после распознавания, а петли («ratos» × 40) и титры
(«Продолжение следует…», «Спасибо за просмотр!») вырезаются точечно
(
textclean.py, тесты —python3 test_textclean.py). Титр режется только на хвосте, за которым нет речи, и только если аудио на это намекает (сегмент похож на тишину, пришёл после паузы или модель в нём не уверена); короткое слово, повторённое пять раз («нет нет нет нет нет»), — речь, а не петля. - История:
history.sqlite3(чистый текст, сырой текст, длительность, приложение-получатель, время ASR и LLM, скорость генерации) — смотреть можно из меню-бара (последние 5), через «Статистика…» / «Поиск истории…» илиsqlite3 history.sqlite3 "SELECT ...". В «Статистике» раздел «Производительность» ведёт хронологию: медианы времени распознавания и длины фразы по дням, последние 60 фраз, у каждой строки поиска —длина → ASR.
- Чистая фраза: ~1 с от отпускания клавиши до вставки.
- Фраза с паразитами: ~1.7 с (+LLM).
- В простое: ~3% одного ядра (открытый аудиопоток), ~4.3 ГБ unified memory под моделями.
- Русский + английские термины распознаются на уровне выше диктовки Apple.
| машина | автоопределение языка | язык фиксирован | GPU |
|---|---|---|---|
| Mac Studio M4 Max | 0.8 с | 0.55 с | 40 ядер |
| MacBook Air M4, 16 ГБ | 2.9 с | 2.0 с | 10 ядер |
Если на 16 ГБ время распознавания стабильно больше самой фразы (в «Статистике» тёмный столбик выше светлого), это почти наверняка нехватка памяти, а не модель: веса вытесняются в своп между диктовками. Лечится выгрузкой модели чистки в простое и закрытием прожорливых приложений (браузер с десятками вкладок), см. «Выгружать модель чистки в простое».