Автор: Дмитрий Сосунов совместно с Claude
Уровень: для тех кто не программист, но не боится терминала
Время: один вечер
Результат: иконка в строке меню Mac → нажал Запустить → говоришь в Claude
Я менеджер, финансист без технического бэкграунда. Мне кайфово диктовать а не печатать, так и не смог я освоить слепой метод, печатаю глядя на клаву. Хочу диктовать. Но!
ПРОБЛЕМА: В Claude пока нет русского языка для диктовки.
Платные решения вроде Voicy работают, но у них лимиты, они требуют интернет и стоят денег. У меня уже был установлен Whisper — тот самый движок OpenAI который все платные решения используют под капотом. Я спросил Claude: можно ли сделать это самому? Оказалось — да, за один вечер.
Вот рассказ о том как мы это построили — включая все грабли на которые наступили по пути.
Что в итоге получилось
-
Иконка 🤖 в строке меню Mac — всегда под рукой
-
Кликнул → выбрал «Запустить» → иконка стала 🎤
-
Нажал кнопку на claude.ai — говоришь по-русски — текст вставляется в поле
-
Whisper работает локально — никаких запросов в облако, никаких лимитов
-
Архитектура расширяемая: захочешь добавить новый AI-инструмент — одна строка в конфиге
-
Бесплатно навсегда
Финальная архитектура
🤖 Menu Bar App (rumps) └── ▶ Запустить Whisper → Claude ↓ Python Flask сервер (localhost:5555) ↓ Chrome Extension (кнопка 🎤 на claude.ai) ↓ аудио в base64 Background Service Worker ↓ POST запрос /opt/homebrew/bin/whisper → текст → поле ввода Claude
Почему такая цепочка а не проще — объясню по ходу. Каждый элемент появился не случайно.
Что понадобится
-
macOS (на Linux аналогично с поправкой на пути)
-
Whisper:
brew install openai-whisper -
ffmpeg:
brew install ffmpeg(Whisper использует его для декодирования аудио) -
Python 3:
python3 --version -
Chrome браузер
Проверь что Whisper работает:
whisper --version
Должен появиться длинный список языков включая Russian. Если видишь список — всё готово.
Шаг 1 — Структура проекта
mkdir -p /путь/к/проектам/whisper-claudecd /путь/к/проектам/whisper-claudepython3 -m venv venvsource venv/bin/activatepip install flask flask-cors rumpsmkdir -p extension/icons
Почему виртуальная среда? macOS начиная с Ventura запрещает устанавливать пакеты глобально — это защита от поломки системного Python. Виртуальная среда решает это чисто.
rumps — Python библиотека для создания Menu Bar приложений на Mac. Позволяет сделать иконку в строке меню в 50 строках кода вместо сотен строк на Swift.
Шаг 2 — Python сервер (server.py)
from flask import Flask, request, jsonifyfrom flask_cors import CORSimport subprocessimport tempfileimport osimport threadingapp = Flask(__name__)CORS(app) # разрешаем запросы от Chrome расширенияWHISPER_PATH = "/opt/homebrew/bin/whisper"WHISPER_MODEL = "small" # tiny/base/small/medium/largePORT = 5555@app.route("/ping", methods=["GET"])def ping(): return jsonify({"status": "ok"})@app.route("/transcribe", methods=["POST"])def transcribe(): if "audio" not in request.files: return jsonify({"error": "Аудио файл не найден"}), 400 audio_file = request.files["audio"] with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as tmp: tmp_path = tmp.name audio_file.save(tmp_path) try: result = subprocess.run( [ WHISPER_PATH, tmp_path, "--model", WHISPER_MODEL, "--language", "ru", "--output_format", "txt", "--output_dir", tempfile.gettempdir(), "--fp16", "False", # обязательно для Mac Apple Silicon ], capture_output=True, text=True, timeout=60 ) txt_path = tmp_path.replace(".webm", ".txt") if os.path.exists(txt_path): with open(txt_path, "r", encoding="utf-8") as f: text = f.read().strip() os.unlink(txt_path) else: text = result.stdout.strip() return jsonify({"text": text}) except subprocess.TimeoutExpired: return jsonify({"error": "Timeout"}), 500 except Exception as e: return jsonify({"error": str(e)}), 500 finally: os.unlink(tmp_path)@app.route("/shutdown", methods=["POST"])def shutdown(): threading.Timer(0.5, lambda: os._exit(0)).start() return jsonify({"status": "shutting down"})if __name__ == "__main__": print(f"[Whisper] Запускаюсь на порту {PORT}, модель: {WHISPER_MODEL}") app.run(host="127.0.0.1", port=PORT, debug=False)
Грабля #1: --fp16 False обязателен на Mac с Apple Silicon. Без него Whisper падает с ошибкой про float16.
Шаг 3 — Menu Bar приложение (menubar.py)
Это сердце системы. Одна иконка в строке меню управляет всеми AI-инструментами. Новый инструмент добавляется одной записью в список TOOLS.
import rumpsimport subprocessimport threadingimport osimport timeimport urllib.requestBASE_DIR = "/путь/к/проекту/whisper-claude"VENV_PYTHON = os.path.join(BASE_DIR, "venv/bin/python3")# ═══════════════════════════════════════# ДОБАВЛЯЙ НОВЫЕ ИНСТРУМЕНТЫ СЮДА# ═══════════════════════════════════════TOOLS = [ { "name": "Whisper → Claude", "script": os.path.join(BASE_DIR, "server.py"), "port": 5555, "description": "Голосовой ввод на русском", }, # Следующий инструмент — просто добавь блок: # { # "name": "Название", # "script": os.path.join(BASE_DIR, "other_tool.py"), # "port": 5556, # "description": "Описание", # },]def is_port_alive(port, timeout=1): try: urllib.request.urlopen(f"http://127.0.0.1:{port}/ping", timeout=timeout) return True except: return Falsedef stop_port(port): try: req = urllib.request.Request( f"http://127.0.0.1:{port}/shutdown", method="POST" ) urllib.request.urlopen(req, timeout=1) except: passclass ToolController: def __init__(self, tool_config, app): self.config = tool_config self.app = app self.process = None self.header = rumps.MenuItem( f"{tool_config['name']} — {tool_config['description']}" ) self.header.set_callback(None) self.toggle_btn = rumps.MenuItem(" ▶ Запустить", callback=self.toggle) self.status_label = rumps.MenuItem(" Статус: ⏸ остановлен") self.status_label.set_callback(None) if is_port_alive(tool_config["port"]): self._set_running() def toggle(self, sender): if is_port_alive(self.config["port"]): self._stop() else: self._start() def _start(self): self.toggle_btn.title = " ⏳ Запускается..." self.status_label.title = " Статус: ⏳ запускается..." def run(): env = os.environ.copy() env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + env.get("PATH", "") self.process = subprocess.Popen( [VENV_PYTHON, self.config["script"]], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) for _ in range(20): time.sleep(0.5) if is_port_alive(self.config["port"]): self._set_running() self.app.update_title() return self.status_label.title = " Статус: ❌ ошибка запуска" self.toggle_btn.title = " ▶ Запустить" threading.Thread(target=run, daemon=True).start() def _stop(self): stop_port(self.config["port"]) if self.process: self.process.terminate() self.process = None self._set_stopped() self.app.update_title() def _set_running(self): self.toggle_btn.title = " ⏹ Остановить" self.status_label.title = " Статус: ✅ работает" def _set_stopped(self): self.toggle_btn.title = " ▶ Запустить" self.status_label.title = " Статус: ⏸ остановлен" def check(self): if is_port_alive(self.config["port"]): self._set_running() else: if self.process: self.process = None self._set_stopped() def is_running(self): return is_port_alive(self.config["port"])class AILauncher(rumps.App): def __init__(self): super().__init__("🤖", quit_button=None) self.controllers = [ToolController(t, self) for t in TOOLS] menu_items = [] for i, ctrl in enumerate(self.controllers): if i > 0: menu_items.append(None) menu_items.append(ctrl.header) menu_items.append(ctrl.toggle_btn) menu_items.append(ctrl.status_label) menu_items.append(None) menu_items.append(rumps.MenuItem("Выйти", callback=self.quit_app)) self.menu = menu_items self.update_title() self.timer = rumps.Timer(self.check_all, 5) self.timer.start() def update_title(self): running = [c for c in self.controllers if c.is_running()] self.title = "🎤" if running else "🤖" def check_all(self, sender): for ctrl in self.controllers: ctrl.check() self.update_title() def quit_app(self, sender): for ctrl in self.controllers: if ctrl.is_running(): stop_port(ctrl.config["port"]) if ctrl.process: ctrl.process.terminate() rumps.quit_application()if __name__ == "__main__": AILauncher().run()
Шаг 4 — Chrome расширение
extension/manifest.json
{ "manifest_version": 3, "name": "Whisper для Claude", "version": "1.0", "description": "Голосовой ввод на русском через локальный Whisper", "permissions": ["activeTab", "scripting"], "host_permissions": [ "https://claude.ai/*", "http://127.0.0.1:5555/*" ], "content_scripts": [ { "matches": ["https://claude.ai/*"], "js": ["content.js"], "run_at": "document_end" } ], "background": { "service_worker": "background.js" }, "action": { "default_popup": "popup.html" }, "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }}
Грабля #2: "microphone" в permissions — в MV3 это невалидное разрешение, Chrome выдаёт предупреждение. Убираем — микрофон запрашивается через navigator.mediaDevices.getUserMedia() прямо в коде.
extension/background.js
Грабля #3 — самая неочевидная: content script на https://claude.ai не может напрямую делать fetch к http://127.0.0.1 — браузер блокирует mixed content. Решение: аудио конвертируется в base64 в content.js, передаётся через chrome.runtime.sendMessage в background.js, и уже оттуда летит на сервер.
const SERVER_URL = "http://127.0.0.1:5555";chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "transcribe") { handleTranscribe(message.audio).then(sendResponse); return true; // асинхронный ответ }});async function handleTranscribe(base64Audio) { try { const binaryStr = atob(base64Audio); const bytes = new Uint8Array(binaryStr.length); for (let i = 0; i < binaryStr.length; i++) { bytes[i] = binaryStr.charCodeAt(i); } const audioBlob = new Blob([bytes], { type: "audio/webm" }); const formData = new FormData(); formData.append("audio", audioBlob, "recording.webm"); const response = await fetch(`${SERVER_URL}/transcribe`, { method: "POST", body: formData, }); return await response.json(); } catch (err) { return { error: "Сервер не запущен" }; }}
extension/content.js
let mediaRecorder = null;let audioChunks = [];let isRecording = false;let micButton = null;function createMicButton() { if (document.getElementById("whisper-mic-btn")) return; micButton = document.createElement("button"); micButton.id = "whisper-mic-btn"; micButton.innerHTML = "🎤"; Object.assign(micButton.style, { position: "fixed", bottom: "100px", right: "30px", width: "56px", height: "56px", borderRadius: "50%", border: "none", background: "#6b7280", color: "white", fontSize: "24px", cursor: "pointer", zIndex: "99999", boxShadow: "0 4px 12px rgba(0,0,0,0.3)", transition: "all 0.2s ease", }); micButton.addEventListener("click", toggleRecording); document.body.appendChild(micButton);}async function toggleRecording() { isRecording ? stopRecording() : await startRecording();}async function startRecording() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); audioChunks = []; mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm" }); mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.onstop = sendAudioToBackground; mediaRecorder.start(); isRecording = true; micButton.innerHTML = "⏹"; micButton.style.background = "#ef4444"; showStatus("🔴 Говори... нажми ещё раз чтобы остановить", "recording"); } catch (err) { showStatus("❌ Нет доступа к микрофону", "error"); }}function stopRecording() { if (mediaRecorder && isRecording) { mediaRecorder.stop(); mediaRecorder.stream.getTracks().forEach(t => t.stop()); isRecording = false; micButton.innerHTML = "⏳"; micButton.style.background = "#f59e0b"; showStatus("⏳ Распознаю речь...", "processing"); }}async function sendAudioToBackground() { const audioBlob = new Blob(audioChunks, { type: "audio/webm" }); const reader = new FileReader(); reader.onloadend = async () => { const base64Audio = reader.result.split(",")[1]; try { const response = await chrome.runtime.sendMessage({ action: "transcribe", audio: base64Audio, }); if (response?.text?.trim()) { insertTextIntoClaude(response.text.trim()); showStatus("✅ Готово!", "success"); } else { showStatus("⚠️ Речь не распознана — попробуй ещё раз", "warning"); } } catch (err) { showStatus("❌ Запусти сервер через иконку в строке меню", "error"); } setTimeout(() => { micButton.innerHTML = "🎤"; micButton.style.background = "#6b7280"; }, 3000); }; reader.readAsDataURL(audioBlob);}function insertTextIntoClaude(text) { const selectors = ['[contenteditable="true"]', 'div[contenteditable]', 'textarea']; let inputField = null; for (const selector of selectors) { for (const el of document.querySelectorAll(selector)) { if (el.offsetParent !== null) { inputField = el; break; } } if (inputField) break; } if (!inputField) return; inputField.focus(); const sep = (inputField.innerText || inputField.value || "").trim() ? " " : ""; if (inputField.tagName === "TEXTAREA") { inputField.value += sep + text; inputField.dispatchEvent(new Event("input", { bubbles: true })); } else { document.execCommand("insertText", false, sep + text); }}let statusEl = null;function showStatus(msg, type) { if (!statusEl) { statusEl = document.createElement("div"); Object.assign(statusEl.style, { position: "fixed", bottom: "165px", right: "20px", padding: "8px 14px", borderRadius: "8px", fontSize: "13px", fontFamily: "system-ui", zIndex: "99999", maxWidth: "280px", boxShadow: "0 2px 8px rgba(0,0,0,0.2)", transition: "opacity 0.3s", }); document.body.appendChild(statusEl); } const colors = { recording: ["#fef2f2","#991b1b"], processing: ["#fffbeb","#92400e"], success: ["#f0fdf4","#166534"], error: ["#fef2f2","#991b1b"], warning: ["#fffbeb","#92400e"], }; const [bg, color] = colors[type] || colors.processing; Object.assign(statusEl.style, { background: bg, color, opacity: "1" }); statusEl.textContent = msg;}window.addEventListener("beforeunload", () => { navigator.sendBeacon("http://127.0.0.1:5555/shutdown");});setTimeout(createMicButton, 2000);new MutationObserver(() => { if (!document.getElementById("whisper-mic-btn")) setTimeout(createMicButton, 1000);}).observe(document.body, { childList: true, subtree: true });
Иконки
python3 -c "import struct, zlibdef make_png(size, color): def chunk(name, data): c = zlib.crc32(name + data) & 0xffffffff return struct.pack('>I', len(data)) + name + data + struct.pack('>I', c) raw = b'' for y in range(size): raw += b'\x00' for x in range(size): raw += bytes(color) ihdr = struct.pack('>IIBBBBB', size, size, 8, 2, 0, 0, 0) return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', ihdr) + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'')for size in [16, 48, 128]: with open(f'extension/icons/icon{size}.png', 'wb') as f: f.write(make_png(size, [107, 114, 128]))print('Иконки созданы')"
Шаг 5 — Установка расширения в Chrome
-
Открой
chrome://extensions -
Включи «Режим разработчика» (правый верхний угол)
-
Нажми «Загрузить распакованное расширение»
-
Выбери папку
extensionвнутри проекта
Шаг 6 — Автозапуск Menu Bar App
start_menubar.sh
#!/bin/bashexport PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"source /путь/к/проекту/whisper-claude/venv/bin/activatepython3 /путь/к/проекту/whisper-claude/menubar.py
chmod +x start_menubar.sh
~/Library/LaunchAgents/com.whisper.claude.plist
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>Label</key> <string>com.whisper.claude</string> <key>ProgramArguments</key> <array> <string>/bin/bash</string> <string>/путь/к/проекту/whisper-claude/start_menubar.sh</string> </array> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <true/> <key>StandardOutPath</key> <string>/tmp/whisper-claude.log</string> <key>StandardErrorPath</key> <string>/tmp/whisper-claude-error.log</string></dict></plist>
launchctl load ~/Library/LaunchAgents/com.whisper.claude.plist
Грабля #4: LaunchAgent запускается в урезанном окружении без стандартного PATH. ffmpeg не находится — Whisper падает с FileNotFoundError: ffmpeg. Решение: явно прописать /opt/homebrew/bin в start_menubar.sh.
Все грабли в одной таблице
|
# |
Грабля |
Симптом |
Решение |
|---|---|---|---|
|
1 |
|
Whisper падает при запуске |
Добавить |
|
2 |
|
Chrome показывает предупреждение |
Убрать из manifest.json |
|
3 |
Mixed content в MV3 |
Кнопка всегда «сервер не запущен» |
Передавать аудио через background.js |
|
4 |
PATH в LaunchAgent |
ffmpeg не найден, распознавание падает |
Прописать |
Как пользоваться каждый день
-
Mac загрузился → иконка 🤖 появилась в строке меню автоматически
-
Хочешь диктовать → кликни иконку → «▶ Запустить» → иконка стала 🎤
-
Перейди на claude.ai → нажми кнопку микрофона в правом нижнем углу → говори → нажми ещё раз
-
Текст вставился в поле — отправляй
Roadmap v2.0
-
Горячая клавиша вместо клика мышью
-
Автоотправка после распознавания
-
Поддержка ChatGPT, Gemini — достаточно добавить новый content script
-
Новые инструменты в меню — одна запись в список
TOOLS
Вывод
Весь проект — один вечер.
Удивляет конечно новая возможность с ИИ делать такие прикольные штуки. ДАже необычно, что сделал сам себе расширение в браузер:
Четыре грабли которые мы поймали — ни одна не была очевидна заранее, но каждая решалась за несколько минут.
Если у тебя уже стоит Whisper: бери код и делай. Если нет: brew install openai-whisper ffmpeg — и вперёд. Спасибо, возможно кто-то уже ищет решение, добро пожаловать.
ссылка на оригинал статьи https://habr.com/ru/articles/1023104/