From 265d99b378804e720cffb470f42bc92834ac649e Mon Sep 17 00:00:00 2001 From: dttb Date: Tue, 5 May 2026 16:27:17 +0300 Subject: [PATCH] =?UTF-8?q?Mac=20dictation:=20Hammerspoon=20+=20Groq=20Whi?= =?UTF-8?q?sper=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decisions/2026-05-05-mac-dictation-groq-hammerspoon.md: полный план, грабли с раскладкой, fallback на whisper-cpp, восстановление на новом Mac - notes/ru-geoblocked-services.md: реестр CDN с RU-блоком (cdn.spokenly, dl.wisprflow и пр.) + принципы обхода - snippets/mac-dictation/: рабочая версия скриптов и init.lua Триггер — одиночный Fn, Groq cloud first → tiny local fallback, вставка через hs.eventtap.event keycode 9 (минует ru-keymap warnings). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-05-05-mac-dictation-groq-hammerspoon.md | 72 ++++++++++++++++ notes/ru-geoblocked-services.md | 30 +++++++ snippets/mac-dictation/README.md | 23 +++++ snippets/mac-dictation/dictation-doctor.sh | 86 +++++++++++++++++++ snippets/mac-dictation/groq-dictate.sh | 70 +++++++++++++++ snippets/mac-dictation/init.lua | 52 +++++++++++ 6 files changed, 333 insertions(+) create mode 100644 decisions/2026-05-05-mac-dictation-groq-hammerspoon.md create mode 100644 notes/ru-geoblocked-services.md create mode 100644 snippets/mac-dictation/README.md create mode 100755 snippets/mac-dictation/dictation-doctor.sh create mode 100755 snippets/mac-dictation/groq-dictate.sh create mode 100644 snippets/mac-dictation/init.lua diff --git a/decisions/2026-05-05-mac-dictation-groq-hammerspoon.md b/decisions/2026-05-05-mac-dictation-groq-hammerspoon.md new file mode 100644 index 0000000..7b14b7f --- /dev/null +++ b/decisions/2026-05-05-mac-dictation-groq-hammerspoon.md @@ -0,0 +1,72 @@ +# 2026-05-05 — Mac dictation: Hammerspoon + Groq Whisper + +## Контекст +Олег на Intel MacBook Pro (i9-9880H, 2019), нужна голосовая диктовка для русского. Все современные приложения (superwhisper PRO, VoiceInk, Spokenly Parakeet) либо требуют Apple Silicon, либо платную PRO-лицензию ($150-250). Бесплатный free-tier у superwhisper исчерпан и валидируется на их сервере (см. [feedback_superwhisper_no_license](../../.claude/projects/-Users-ai-knowledge-base/memory/feedback_superwhisper_no_license.md)). + +## Решение +Свой скрипт + Hammerspoon + Groq Whisper API: +- **Hammerspoon** ловит глобальный hotkey `⌘⇧D` +- **Bash скрипт** toggle-режим: первый запуск → ffmpeg запись с микрофона; второй → стоп → POST в Groq → текст в `/tmp/groq-dictate.last` +- **Lua callback** в Hammerspoon читает файл, кладёт в pasteboard, нажимает Cmd+V через `hs.eventtap.keyStroke` +- **Groq Whisper-large-v3-turbo** — бесплатно ~14400 запросов/день, 0.5с на 4-сек запись, RU IP не блочится + +## Файлы +- `~/bin/groq-dictate.sh` — скрипт записи + Groq POST + **fallback на whisper-cpp** + write to `/tmp/groq-dictate.last` +- `~/bin/dictation-doctor.sh` — health-check всех компонентов (Hammerspoon / TCC / зависимости / Groq / mic / Fn-key); запускать когда «не работает» +- `~/.hammerspoon/init.lua` — Fn (одиночное нажатие) trigger через eventtap, paste через `hs.eventtap.keyStroke` +- `~/.cache/whisper-cpp/ggml-tiny-q5_1.bin` — 31MB локальная модель для offline fallback +- Groq API key — в `reference_groq_api.md` private memory + +## Финальный hotkey +**Fn (Globe) одиночное нажатие** — toggle (старт/стоп). Срабатывает быстрее ⌘⇧D, освобождает руки. Apple Dictation на двойное Fn остаётся (если не отключить «Нажатие клавиши Fn» в System Settings → Keyboard). + +## Критические грабли (и фиксы) +1. **Hammerspoon Accessibility кеширует статус** — после Enable в System Settings нужен **`killall Hammerspoon && open -a Hammerspoon`**, иначе Hammerspoon продолжает показывать WARNING и hotkey не работает. + +2. **`hs.hotkey.bind({"cmd","shift"}, "d", ...)`** на русской раскладке выдаёт warning `key 'd' not found in active keymap; using ANSI-standard US keyboard layout as fallback, returning '2'`. Решение: **биндить по числовому keycode** — `hs.hotkey.bind({"cmd","shift"}, 2, ...)` (2 = физическая клавиша D). Так работает на любой раскладке. + +3. **`osascript -e 'tell application "System Events" to keystroke "v" using command down'`** на русской раскладке вместо Cmd+V вставляет UTF-8 байты текста как символы → получается мусор типа `—В—ь—А—∞—ь—В?` для строки «Ты меня слышишь?». Решение: **никогда не использовать `keystroke` для paste**. Использовать `hs.eventtap.keyStroke({"cmd"}, "v")` напрямую из Lua (отправляет настоящий low-level KeyDown event). + +4. **Toggle через PID-файл** — `/tmp/groq-dictate.pid`. Если процесс упал/убит — удалить руками. Скрипт устойчив: `kill -INT` корректно закрывает .wav, ждёт до 0.5с дописать заголовок. + +5. **ffmpeg avfoundation `:0`** = default mic. Если нужен другой — `ffmpeg -f avfoundation -list_devices true -i ""`. + +## Стоимость +- Hammerspoon бесплатный +- Groq бесплатный (14400 req/day, ~120 минут диктовки в день — намного больше нужного) +- Итого: 0₽ + +## Альтернативы которые НЕ подошли +- **superwhisper** — free tier 530 сек, потом сервер бракует +- **VoiceInk** — официально Apple Silicon only, на Intel CPU крутит большую модель часами +- **Spokenly** — `cdn.spokenly.app` блокирует RU IP (3.8 KB/s), Parakeet требует Neural Engine +- **Wispr Flow** — `dl.wisprflow.com` блокирует RU IP, плюс $144/год Pro для регулярного использования +- **MacWhisper** — $59 lifetime + не пробовали (всё уже работало бесплатно) +- **OpenWhispr** — 273MB dmg в GitHub, не докачался + +## Связано +- [RU-заблокированные сервисы](../notes/ru-geoblocked-services.md) +- [Groq API](../../.claude/projects/-Users-ai-knowledge-base/memory/reference_groq_api.md) +- [Superwhisper — нет PRO](../../.claude/projects/-Users-ai-knowledge-base/memory/feedback_superwhisper_no_license.md) + +## Воспроизведение на новом Mac +```bash +brew install --cask hammerspoon +brew install whisper-cpp jq ffmpeg +mkdir -p ~/bin ~/.hammerspoon ~/.cache/whisper-cpp + +# скопировать ~/bin/groq-dictate.sh + ~/bin/dictation-doctor.sh → chmod +x +# скопировать ~/.hammerspoon/init.lua + +# скачать локальную fallback-модель (31 MB, GitHub не блочит RU) +curl -sSL -o ~/.cache/whisper-cpp/ggml-tiny-q5_1.bin \ + "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny-q5_1.bin?download=true" + +open -a Hammerspoon +# Hammerspoon Preferences → Enable Accessibility → System Settings → включить +killall Hammerspoon && open -a Hammerspoon # ОБЯЗАТЕЛЬНО после grant — кеш + +# System Settings → Клавиатура → Нажатие клавиши Fn → "Действие не требуется" +~/bin/dictation-doctor.sh # должно быть всё зелёное +``` +Готово. **Fn → говори → Fn** — текст вставится в активное окно. diff --git a/notes/ru-geoblocked-services.md b/notes/ru-geoblocked-services.md new file mode 100644 index 0000000..fe84094 --- /dev/null +++ b/notes/ru-geoblocked-services.md @@ -0,0 +1,30 @@ +# Сервисы, заблокированные для RU IP + +Реестр CDN/сайтов/сервисов, которые блокируют российские IP — чтобы не искать причину каждый раз. Обход — через NetBird `Trance` group → finland exit-node, либо через App Store (Apple не блочит RU аккаунты), либо через GitHub Releases (не блочит). + +## Скачивание / CDN + +| Сервис | Домен | Симптом | Обход | +|---|---|---|---| +| **superwhisper PRO API** | `*.superwhisper.com` (api endpoints) | Облачные модели возвращают пустой `result`, free tier 530 сек кончился | Купить PRO ($249) или альтернативы | +| **Spokenly CDN** | `cdn.spokenly.app` | TCP качает ~150 Б/с, dmg на 21 MB → 36 мин не докачивается | App Store / NetBird finland / VoiceInk вместо | +| **MS Windows Update / setup.exe** | `download.microsoft.com`, `dl.delivery.mp.microsoft.com` | Геоблок | NetBird `Trance` group → finland exit-node ([feedback_win11_unattended_upgrade](../../.claude/projects/-Users-ai-knowledge-base/memory/feedback_win11_unattended_upgrade.md)) | +| **Apple ID TJ** | account.apple.com (region change) | KYC требует TJ-резидентства | IPRoyal residential proxy (см. [2026-05-02-apple-id-tj-via-residential-proxy](../decisions/2026-05-02-apple-id-tj-via-residential-proxy.md)) | +| **НСПД (gov.ru, кадастр)** | `nspd.gov.ru`, customers_p2p_b16 | МТС B2B блокирует | NetBird route `2.63.246.0/24` → `pve-LionART` ([feedback_nspd_blocks_mts](../../.claude/projects/-Users-ai-knowledge-base/memory/feedback_nspd_blocks_mts.md)) | +| **gov.ru разные** | `*.gov.ru` | Несколько классов: FakeIP, WAF-MTS, ru-trust, ГОСТ-mTLS, anti-bot | См. playbook `projects/niikn/govru-quickfix-playbook.md` | + +## Принцип +- Если при первом запуске любой `brew install --cask`, `curl`, `wget`, `npm install` падает по timeout с CDN — **сразу** проверять RU-блок (curl с timeout 5 + look at recv speed). +- Не дёргать `--retry` / `-C -` — потеря времени. +- Default решения: 1) App Store, 2) GitHub Releases, 3) NetBird finland exit-node, 4) IPRoyal residential. + +## Проверка скорости (быстрый snippet) +```bash +curl -sS -o /dev/null --max-time 5 -w "HTTP %{http_code} speed=%{speed_download}B/s\n" +``` +Если speed < 50 KB/s на CDN — почти наверняка геоблок/throttle. + +## Альтернативы по категориям +- **Mac dictation app** (вместо superwhisper / Spokenly из CDN): VoiceInk из GitHub Releases (open source, $40 lifetime или собрать самому), Apple Dictation встроенная. +- **Win update** (MS блок): NetBird Trance → finland. +- **gov.ru**: см. govru playbook. diff --git a/snippets/mac-dictation/README.md b/snippets/mac-dictation/README.md new file mode 100644 index 0000000..9b730e2 --- /dev/null +++ b/snippets/mac-dictation/README.md @@ -0,0 +1,23 @@ +# Mac Dictation — Hammerspoon + Groq + +Текущая рабочая версия (2026-05-05) скриптов для голосовой диктовки в любое поле macOS через Groq Whisper API. + +## Файлы +- `groq-dictate.sh` → `~/bin/groq-dictate.sh` (chmod +x) +- `dictation-doctor.sh` → `~/bin/dictation-doctor.sh` (chmod +x) +- `init.lua` → `~/.hammerspoon/init.lua` + +## Как это работает +- **Fn (Globe)** — одиночное нажатие, toggle (старт/стоп записи) +- Запись через `ffmpeg avfoundation :0` → `/tmp/groq-dictate.wav` +- Транскрипция: **Groq Whisper-large-v3-turbo** (cloud), fallback → **whisper-cpp tiny** (local 31MB) +- Результат → pasteboard → `hs.eventtap.event.newKeyEvent({"cmd"}, 9, true|false):post()` (⌘V на физический keycode 9 = V) + +## Полный гайд +[`decisions/2026-05-05-mac-dictation-groq-hammerspoon.md`](../../decisions/2026-05-05-mac-dictation-groq-hammerspoon.md) — детали, грабли, восстановление на новом Mac. + +## Если сломалось +```bash +~/bin/dictation-doctor.sh +``` +Покажет что сломано (Hammerspoon / TCC / Groq / mic / Fn behavior). diff --git a/snippets/mac-dictation/dictation-doctor.sh b/snippets/mac-dictation/dictation-doctor.sh new file mode 100755 index 0000000..7df2334 --- /dev/null +++ b/snippets/mac-dictation/dictation-doctor.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Health-check для голосовой диктовки. Запускать когда «не работает». +# Проверяет: Hammerspoon, Accessibility, init.lua, скрипт, модели, Groq, ffmpeg, mic, Fn-key поведение + +set +e +GREEN="\033[32m"; RED="\033[31m"; YELLOW="\033[33m"; CYAN="\033[36m"; RST="\033[0m" +ok() { echo -e "${GREEN}✅ $1${RST}"; } +fail() { echo -e "${RED}❌ $1${RST}"; ((FAILS++)); } +warn() { echo -e "${YELLOW}⚠️ $1${RST}"; } +hdr() { echo -e "\n${CYAN}── $1 ──${RST}"; } +FAILS=0 + +hdr "1. Hammerspoon" +if pgrep -x Hammerspoon >/dev/null; then ok "Hammerspoon запущен (pid $(pgrep -x Hammerspoon))" +else fail "Hammerspoon не запущен → open -a Hammerspoon"; fi + +if [ -f ~/.hammerspoon/init.lua ]; then ok "init.lua на месте" +else fail "~/.hammerspoon/init.lua отсутствует"; fi + +hdr "2. Accessibility (TCC)" +TCC=$(echo " " | sudo -S sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \ + "SELECT auth_value FROM access WHERE service='kTCCServiceAccessibility' AND client='org.hammerspoon.Hammerspoon'" 2>/dev/null) +case "$TCC" in + 2) ok "Hammerspoon Accessibility: ALLOWED" ;; + 0) fail "Hammerspoon Accessibility: DENIED → System Settings → Universal Access → включить" ;; + "") fail "Hammerspoon в TCC отсутствует → System Settings → Universal Access → добавить и включить" ;; + *) warn "Hammerspoon TCC: $TCC (странное значение)" ;; +esac + +hdr "3. Скрипт диктовки" +if [ -x ~/bin/groq-dictate.sh ]; then ok "~/bin/groq-dictate.sh executable" +else fail "~/bin/groq-dictate.sh отсутствует или не +x"; fi + +hdr "4. Зависимости" +for cmd in ffmpeg jq curl whisper-cli osascript; do + if command -v "$cmd" >/dev/null; then ok "$cmd найден ($(command -v $cmd))" + else fail "$cmd не установлен → brew install $cmd"; fi +done + +hdr "5. Модели" +if [ -f "$HOME/.cache/whisper-cpp/ggml-tiny-q5_1.bin" ]; then + SIZE=$(ls -lh "$HOME/.cache/whisper-cpp/ggml-tiny-q5_1.bin" | awk '{print $5}') + ok "whisper-cpp tiny: $SIZE" +else + warn "локальная модель whisper-cpp отсутствует (fallback будет недоступен)" +fi + +hdr "6. Groq API" +HTTP=$(curl -sS -o /tmp/_groq_test.json -w "%{http_code}" --max-time 10 \ + -H "Authorization: Bearer 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" \ + https://api.groq.com/openai/v1/models 2>/dev/null) +case "$HTTP" in + 200) ok "Groq API отвечает (HTTP 200)" ;; + 401) fail "Groq: 401 — ключ невалиден или просрочен" ;; + 403) fail "Groq: 403 — доступ запрещён (возможно, RU IP заблокирован)" ;; + 429) warn "Groq: 429 — rate limit, подожди" ;; + 000) fail "Groq не отвечает (нет сети?)" ;; + *) fail "Groq HTTP $HTTP" ;; +esac +rm -f /tmp/_groq_test.json + +hdr "7. Микрофон" +DEFAULT_MIC=$(system_profiler SPAudioDataType 2>/dev/null | awk '/Default Input Device: Yes/{found=1} found && /Input Source:/{print $3, $4, $5; exit}') +[ -n "$DEFAULT_MIC" ] && ok "Default mic: $DEFAULT_MIC" || warn "Не удалось определить default mic" + +hdr "8. Fn-key behavior" +FN_PREF=$(defaults read com.apple.HIToolbox AppleFnUsageType 2>/dev/null) +case "$FN_PREF" in + 0) ok "Fn = 'действие не требуется' (правильно)" ;; + 1) warn "Fn = 'переключает источник ввода' — конфликт с диктовкой" ;; + 2) warn "Fn = 'эмодзи панель' — конфликт" ;; + 3) warn "Fn = 'начинает Apple Dictation' — КОНФЛИКТ! Поменяй на 'Не требуется'" ;; + *) warn "Fn behavior unknown: $FN_PREF" ;; +esac + +hdr "9. Лог последних попыток" +[ -f /tmp/groq-dictate.log ] && tail -10 /tmp/groq-dictate.log || warn "Лог пуст" + +echo +if [ $FAILS -eq 0 ]; then + echo -e "${GREEN}═══ Всё в порядке ═══${RST}" + exit 0 +else + echo -e "${RED}═══ Найдено проблем: $FAILS ═══${RST}" + exit 1 +fi diff --git a/snippets/mac-dictation/groq-dictate.sh b/snippets/mac-dictation/groq-dictate.sh new file mode 100755 index 0000000..7799caf --- /dev/null +++ b/snippets/mac-dictation/groq-dictate.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Toggle dictation: ⌘⇧D / Fn → запись с микрофона; повторное нажатие → Groq Whisper → текст в /tmp/groq-dictate.last +# Hammerspoon Lua callback вставляет через hs.eventtap.keyStroke({"cmd"},"v") в активное окно. +# Fallback: если Groq недоступен (нет сети / 429 / 5xx) → локальный whisper-cli (whisper-cpp tiny ru). + +PID_FILE="/tmp/groq-dictate.pid" +WAV_FILE="/tmp/groq-dictate.wav" +WAV_16K="/tmp/groq-dictate-16k.wav" +OUT_FILE="/tmp/groq-dictate.last" +LOG="/tmp/groq-dictate.log" +GROQ_KEY="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" +WHISPER_MODEL="$HOME/.cache/whisper-cpp/ggml-tiny-q5_1.bin" + +log() { echo "[$(date +%H:%M:%S)] $*" >> "$LOG"; } +notify() { osascript -e "display notification \"$1\" with title \"Groq Dictate\""; } + +groq_transcribe() { + local response + response=$(curl -sS -X POST "https://api.groq.com/openai/v1/audio/transcriptions" \ + -H "Authorization: Bearer $GROQ_KEY" \ + -F "file=@$WAV_FILE" \ + -F "model=whisper-large-v3-turbo" \ + -F "language=ru" \ + -F "response_format=json" \ + --max-time 15 2>/dev/null) || return 1 + local code + code=$(echo "$response" | jq -r '.error.code // empty' 2>/dev/null) + [ -n "$code" ] && { log "Groq error: $response"; return 1; } + echo "$response" | jq -r '.text // empty' 2>/dev/null | sed 's/^ *//;s/ *$//' +} + +local_transcribe() { + [ ! -f "$WHISPER_MODEL" ] && { log "no local model: $WHISPER_MODEL"; return 1; } + ffmpeg -hide_banner -loglevel error -i "$WAV_FILE" -ar 16000 -ac 1 -y "$WAV_16K" 2>>"$LOG" || return 1 + whisper-cli -m "$WHISPER_MODEL" -l ru -nt -np "$WAV_16K" 2>>"$LOG" | sed 's/^ *//;s/ *$//' | tr -d '\n' +} + +if [ -f "$PID_FILE" ]; then + # === STOP & TRANSCRIBE === + PID=$(cat "$PID_FILE") + log "STOP pid=$PID" + kill -INT "$PID" 2>/dev/null + for i in 1 2 3 4 5; do kill -0 "$PID" 2>/dev/null || break; sleep 0.1; done + rm -f "$PID_FILE" + + if [ ! -s "$WAV_FILE" ]; then + log "ERR empty wav"; notify "Запись пустая"; exit 1 + fi + + log "POST size=$(stat -f%z "$WAV_FILE")B" + TEXT=$(groq_transcribe) + if [ -z "$TEXT" ]; then + log "Groq fail → trying local whisper-cli" + notify "☁️→💻 Groq не отвечает, локальная модель" + TEXT=$(local_transcribe) + [ -z "$TEXT" ] && { log "Local also empty"; notify "Не распознано"; exit 1; } + log "LOCAL DONE: $TEXT" + else + log "GROQ DONE: $TEXT" + fi + + printf '%s' "$TEXT" > "$OUT_FILE" +else + # === START RECORDING === + rm -f "$WAV_FILE" "$WAV_16K" "$OUT_FILE" + log "START → $WAV_FILE" + ffmpeg -hide_banner -loglevel error -f avfoundation -i ":0" -ar 16000 -ac 1 -y "$WAV_FILE" >/dev/null 2>>"$LOG" & + echo $! > "$PID_FILE" + notify "🎙️ Говори" +fi diff --git a/snippets/mac-dictation/init.lua b/snippets/mac-dictation/init.lua new file mode 100644 index 0000000..09381e1 --- /dev/null +++ b/snippets/mac-dictation/init.lua @@ -0,0 +1,52 @@ +-- Groq Dictate: trigger по одиночному нажатию Fn (Globe) +-- Fn нельзя биндить через hs.hotkey, поэтому слушаем flagsChanged + +local SCRIPT = os.getenv("HOME") .. "/bin/groq-dictate.sh" + +local function runScript() + hs.task.new("/bin/bash", function(exitCode, stdOut, stdErr) + local f = io.open("/tmp/groq-dictate.last", "r") + if f then + local text = f:read("*a") + f:close() + os.remove("/tmp/groq-dictate.last") + if text and #text > 0 then + hs.pasteboard.setContents(text) + -- keycode 9 = физическая V, в обход keymap lookup чтобы не спамить warnings на ru-раскладке + hs.eventtap.event.newKeyEvent({"cmd"}, 9, true):post() + hs.eventtap.event.newKeyEvent({"cmd"}, 9, false):post() + end + end + end, {SCRIPT}):start() +end + +-- Отслеживаем Fn: keycode 63 — это physical Fn/Globe +-- Триггер по press (а не по release), чтобы было снапи +local fnPressed = false +local fnTime = 0 + +fnTap = hs.eventtap.new({hs.eventtap.event.types.flagsChanged}, function(event) + local kc = event:getKeyCode() + if kc ~= 63 then return false end -- 63 = Fn/Globe + + local flags = event:getFlags() + local now = hs.timer.absoluteTime() / 1e6 -- ms + + if flags.fn and not fnPressed then + -- Fn pressed + fnPressed = true + fnTime = now + elseif (not flags.fn) and fnPressed then + -- Fn released + fnPressed = false + local elapsed = now - fnTime + -- Только короткое нажатие (<400ms) триггерит — long-press для других целей + if elapsed < 400 then + runScript() + end + end + return false -- не блокировать event для других слушателей +end) +fnTap:start() + +hs.alert.show("Groq Dictate: Fn (Globe) ready")