Skip to content

Repository files navigation

dictate

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 с) → 🎙️ готов → 🟠 идёт запись; ⚠️ — аудиопоток не отдаёт данные (нет ни одного микрофона: Mac Studio без встроенного, AirPods в кейсе) — как только микрофон появится, подхватится сам.

Скачивание и прогрев разведены намеренно: первое — это гигабайты по сети (на медленном канале — часы), второе — секунды с диска. Пока идёт закачка, в окне состояния видно, сколько скачано, с какой скоростью и сколько осталось; та же строка раз в полминуты уходит в лог. Если данные перестали идти, там будет «данные не идут уже 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.lockuv 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 --tags

Демон (launchd)

daemon.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.

Замеры (MacBook M5, 16 ГБ)

  • Чистая фраза: ~1 с от отпускания клавиши до вставки.
  • Фраза с паразитами: ~1.7 с (+LLM).
  • В простое: ~3% одного ядра (открытый аудиопоток), ~4.3 ГБ unified memory под моделями.
  • Русский + английские термины распознаются на уровне выше диктовки Apple.

Скорость ASR по машинам (whisper-large-v3-turbo, фраза 8.9 с, 27.08.2026)

машина автоопределение языка язык фиксирован GPU
Mac Studio M4 Max 0.8 с 0.55 с 40 ядер
MacBook Air M4, 16 ГБ 2.9 с 2.0 с 10 ядер

Если на 16 ГБ время распознавания стабильно больше самой фразы (в «Статистике» тёмный столбик выше светлого), это почти наверняка нехватка памяти, а не модель: веса вытесняются в своп между диктовками. Лечится выгрузкой модели чистки в простое и закрытием прожорливых приложений (браузер с десятками вкладок), см. «Выгружать модель чистки в простое».

About

Локальная офлайн push-to-talk диктовка для macOS (Apple Silicon): Whisper large-v3-turbo на MLX + Qwen3-чистка речи, словарь терминов, VAD, голосовые команды, меню-бар, launchd-демон. · Local offline push-to-talk dictation for macOS on MLX: Whisper large-v3-turbo + Qwen3 cleanup, custom vocabulary, VAD, voice commands, menu bar app, launchd daemon.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages