Mac dictation: Hammerspoon + Groq Whisper решение

- 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) <noreply@anthropic.com>
This commit is contained in:
dttb
2026-05-05 16:27:17 +03:00
parent 89fbfec1b8
commit 265d99b378
6 changed files with 333 additions and 0 deletions

View File

@@ -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).

View File

@@ -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

View File

@@ -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

View File

@@ -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")